diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index 3504f46..b4cdf8a 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -4,9 +4,9 @@ name: Tests CI on: # Triggers the workflow on push or pull request events but only for the branches below push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] # Allows one to run this workflow manually from the Actions tab workflow_dispatch: @@ -20,9 +20,11 @@ jobs: strategy: matrix: # Test for the following Typst versions - # 0.4.0 (earliest supported), 0.6.0 (first version with package management), - # 0.11.0 (latest supported) - typst-version: [v0.4.0, v0.6.0, v0.11.0] + # 0.7.0 (earliest supported), 0.8.0, + # 0.9.0 (first version with version checks), 0.10.0, + # 0.11.0, 0.11.1, 0.12-rc1 (latest supported) + typst-version: + [v0.7.0, v0.8.0, v0.9.0, v0.10.0, v0.11.0, v0.11.1, v0.12.0-rc1] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -35,10 +37,5 @@ jobs: with: typst-version: ${{ matrix.typst-version }} - - name: 🛠️ Compile test document () +#let using-080 = type(type(5)) != _str-type +#let using-090 = using-080 and str(-1).codepoints().first() == "\u{2212}" +#let using-0110 = using-090 and sys.version >= version(0, 11, 0) + +#let _arr-chunks = if using-0110 { + array.chunks +} else { + (arr, chunks) => { + let i = 0 + let res = () + for element in arr { + if i == 0 { + res.push(()) + i = chunks + } + res.last().push(element) + i -= 1 + } + res + } +} + +#let _float-is-nan = if using-0110 { + float.is-nan +} else { + x => type(x) == _float-type and "NaN" in repr(x) +} + +#let _float-is-infinite = if using-0110 { + float.is-infinite +} else { + x => type(x) == _float-type and "inf" in repr(x) +} + #let _strfmt_formatparser(s) = { if type(s) != _str-type { panic("String format parsing internal error: String format parser given non-string.") @@ -195,7 +229,7 @@ } #let _strfmt_with-precision(num, precision) = { - if precision == none { + if precision == none or type(num) == _float-type and (_float-is-nan(num) or _float-is-infinite(num)) { return _strfmt_stringify(num) } let result = _strfmt_stringify(calc.round(float(num), digits: calc.min(50, precision))) @@ -228,7 +262,7 @@ let mantissa = f / calc.pow(10, exponent) let mantissa = _strfmt_with-precision(mantissa, precision) - mantissa + exponent-sign + _strfmt_stringify(exponent) + (mantissa, exponent-sign + _strfmt_stringify(exponent)) } // Parses {format:specslikethis}. @@ -244,16 +278,37 @@ type := '' | '?' | 'x?' | 'X?' | identifier count := parameter | integer parameter := argument '$' */ -#let _generate-replacement(fullname, extras, replacement, pos-replacements: (), named-replacements: (:), fmt-decimal-separator: auto) = { +#let _generate-replacement( + fullname, extras, replacement, + pos-replacements: (), named-replacements: (:), + fmt-decimal-separator: auto, + fmt-thousands-count: 3, + fmt-thousands-separator: "" +) = { if extras == none { - if not _strfmt_is-numeric-type(replacement) { - fmt-decimal-separator = auto - } - replacement = _strfmt_stringify(replacement) - if fmt-decimal-separator not in (auto, none) { - replacement = replacement.replace(".", _strfmt_stringify(fmt-decimal-separator)) + let is-numeric = _strfmt_is-numeric-type(replacement) + + if is-numeric { + let is-nan = type(replacement) == _float-type and _float-is-nan(replacement) + let string-replacement = _strfmt_stringify(calc.abs(replacement)) + let sign = if not is-nan and replacement < 0 { "-" } else { "" } + let (integral, ..fractional) = string-replacement.split(".") + if fmt-thousands-separator != "" and (type(replacement) != _float-type or not _float-is-nan(replacement) and not _float-is-infinite(replacement)) { + integral = _arr-chunks(integral.codepoints().rev(), fmt-thousands-count) + .join(fmt-thousands-separator.codepoints().rev()) + .rev() + .join() + } + + if fractional.len() > 0 { + let decimal-separator = if fmt-decimal-separator not in (auto, none) { _strfmt_stringify(fmt-decimal-separator) } else { "." } + return sign + integral + decimal-separator + fractional.first() + } else { + return sign + integral + } + } else { + return _strfmt_stringify(replacement) } - return replacement } let extras = _strfmt_stringify(extras) // note: usage of [\s\S] in regex to include all characters, incl. newline @@ -345,6 +400,7 @@ parameter := argument '$' let is-numeric = _strfmt_is-numeric-type(replacement) if is-numeric { + let is-nan = type(replacement) == _float-type and _float-is-nan(replacement) if zero { // disable fill, we will be prefixing with zeroes if necessary fill = none @@ -358,9 +414,9 @@ parameter := argument '$' } // if + is specified, + will appear before all numbers >= 0. - if sign == "+" and replacement >= 0 { + if sign == "+" and not is-nan and replacement >= 0 { sign = "+" - } else if replacement < 0 { + } else if not is-nan and replacement < 0 { sign = "-" } else { sign = "" @@ -369,37 +425,65 @@ parameter := argument '$' // we'll add the sign back later! replacement = calc.abs(replacement) + // Separate integral from fractional parts + // We'll recompose them later + let integral = "" + let fractional = () + let exponent-suffix = "" + if spectype in ("e", "E") { let exponent-sign = if spectype == "E" { "E" } else { "e" } - replacement = _strfmt_exp-format(calc.abs(replacement), exponent-sign: exponent-sign, precision: precision) + let (mantissa, exponent) = _strfmt_exp-format(calc.abs(replacement), exponent-sign: exponent-sign, precision: precision) + (integral, ..fractional) = mantissa.split(".") + exponent-suffix = exponent } else if type(replacement) != _int-type and precision != none { - replacement = _strfmt_with-precision(replacement, precision) + let new-replacement = _strfmt_with-precision(replacement, precision) + (integral, ..fractional) = new-replacement.split(".") } else if type(replacement) == _int-type and spectype in ("x", "X", "b", "o", "x?", "X?") { let radix-map = (x: 16, X: 16, "x?": 16, "X?": 16, b: 2, o: 8) let radix = radix-map.at(spectype) let lowercase = spectype.starts-with("x") - replacement = _strfmt_stringify(_strfmt_display-radix(replacement, radix, lowercase: lowercase, signed: false)) + integral = _strfmt_stringify(_strfmt_display-radix(replacement, radix, lowercase: lowercase, signed: false)) if hashtag { let hashtag-prefix-map = ("16": "0x", "2": "0b", "8": "0o") hashtag-prefix = hashtag-prefix-map.at(str(radix)) } } else { precision = none - replacement = if spectype.ends-with("?") { - repr(replacement) + let new-replacement = if spectype.ends-with("?") { + let repr-res = repr(replacement) + if using-090 and not using-0110 and type(replacement) == _float-type and "." not in repr-res { + // Workaround for repr inconsistency in Typst 0.9.0 and 0.10.0 + repr-res + ".0" + } else { + repr-res + } } else { _strfmt_stringify(replacement) } + (integral, ..fractional) = new-replacement.split(".") } - if fmt-decimal-separator not in (auto, none) { - replacement = replacement.replace(".", _strfmt_stringify(fmt-decimal-separator)) - } + + let decimal-separator = if fmt-decimal-separator not in (auto, none) { _strfmt_stringify(fmt-decimal-separator) } else { "." } + let replaced-fractional = if fractional.len() > 0 { decimal-separator + fractional.join(decimal-separator) } else { "" } + let exponent-suffix = exponent-suffix.replace(".", decimal-separator) + if zero { - let width-diff = width - (replacement.len() + sign.len() + hashtag-prefix.len()) + let width-diff = width - (integral.len() + replaced-fractional.codepoints().len() + sign.len() + hashtag-prefix.len() + exponent-suffix.codepoints().len()) if width-diff > 0 { // prefix with the appropriate amount of zeroes - replacement = ("0" * width-diff) + replacement + integral = ("0" * width-diff) + integral } } + + // Format with thousands AFTER zeroes, but BEFORE applying textual prefixes + if fmt-thousands-separator != "" and (type(replacement) != _float-type or not _float-is-nan(replacement) and not _float-is-infinite(replacement)) { + integral = _arr-chunks(integral.codepoints().rev(), fmt-thousands-count) + .join(fmt-thousands-separator.codepoints().rev()) + .rev() + .join() + } + + replacement = integral + replaced-fractional + exponent-suffix } else { sign = "" hashtag-prefix = "" @@ -426,7 +510,7 @@ parameter := argument '$' if fill != none { // perform fill/width adjustments: "x" ---> " x" if width is 4 - let width-diff = width - replacement.len() // number prefixes are also considered for width + let width-diff = width - replacement.codepoints().len() // number prefixes are also considered for width if width-diff > 0 { if align == left { replacement = replacement + (fill * width-diff) @@ -449,6 +533,21 @@ parameter := argument '$' let named-replacements = replacements.named() let unnamed-format-index = 0 let fmt-decimal-separator = if "fmt-decimal-separator" in named-replacements { named-replacements.at("fmt-decimal-separator") } else { auto } + let fmt-thousands-count = if "fmt-thousands-count" in named-replacements { named-replacements.at("fmt-thousands-count") } else { 3 } + let fmt-thousands-separator = if "fmt-thousands-separator" in named-replacements { named-replacements.at("fmt-thousands-separator") } else { "" } + + assert( + type(fmt-thousands-count) == _int-type, + message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + type(fmt-thousands-count) + "' instead." + ) + assert( + fmt-thousands-count > 0, + message: "String formatter error: 'fmt-thousands-count' must be a positive integer, got " + str(fmt-thousands-count) + " instead." + ) + assert( + type(fmt-thousands-separator) == _str-type, + message: "String formatter error: 'fmt-thousands-separator' must be a string (or empty string, \"\", to disable), got '" + type(fmt-thousands-separator) + "' instead." + ) let parts = () let last-span-end = 0 @@ -490,7 +589,7 @@ parameter := argument '$' } replace-by = named-replacements.at(name) } - replace-by = _generate-replacement(f.name, extras, replace-by, pos-replacements: num-replacements, named-replacements: named-replacements, fmt-decimal-separator: fmt-decimal-separator) + replace-by = _generate-replacement(f.name, extras, replace-by, pos-replacements: num-replacements, named-replacements: named-replacements, fmt-decimal-separator: fmt-decimal-separator, fmt-thousands-count: fmt-thousands-count, fmt-thousands-separator: fmt-thousands-separator) replace-span = f.span } else { panic("String formatter error: Internal error (unexpected format received).") diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index 27862c1..03d8309 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -56,6 +56,46 @@ // test grapheme clusters assert.eq(strfmt("Ĺo͂řȩ{}m̅", 5.5), "Ĺo͂řȩ5.5m̅") + + // padding should use codepoint len + assert.eq(strfmt("{:€>15}", "Ĺo͂řȩ5.5m̅"), "€€€€€Ĺo͂řȩ5.5m̅") + assert.eq(strfmt("{:€>10}", "abc€d"), "€€€€€abc€d") +} +// Issue #5: Thousands +#{ + // Test separator + assert.eq(strfmt("{}", 10, fmt-thousands-separator: "_"), "10") + assert.eq(strfmt("{}", 1000, fmt-thousands-separator: ""), "1000") + assert.eq(strfmt("{}", 1000, fmt-thousands-separator: "_"), "1_000") + assert.eq(strfmt("{}", 100000000, fmt-thousands-separator: "_"), "100_000_000") + assert.eq(strfmt("{}", 100000000.0, fmt-thousands-separator: "_"), "100_000_000") + assert.eq(strfmt("{}", 10000000.3231, fmt-thousands-separator: "_"), "10_000_000.3231") + assert.eq(strfmt("{}", -230, fmt-thousands-separator: "_"), "-230") + assert.eq(strfmt("{}", -2300, fmt-thousands-separator: "_"), "-2_300") + assert.eq(strfmt("{}", -2300.453, fmt-thousands-separator: "_"), "-2_300.453") + assert.eq(strfmt("{}", 5555.2, fmt-thousands-separator: "€", fmt-decimal-separator: "€€"), "5€555€€2") + assert.eq(strfmt("{:010}", -23003, fmt-thousands-separator: "abc"), "-000abc023abc003") + assert.eq(strfmt("{:+013}", 23003.34, fmt-thousands-separator: "abc"), "+000abc023abc003.34") + assert.eq(strfmt("{:#b}", 255, fmt-thousands-separator: "_"), "0b11_111_111") + assert.eq(strfmt("{:#x}", -16 * 16 * 16 * 16 * 15, fmt-thousands-separator: "_"), "-0xf0_000") + assert.eq(strfmt("{:o}", -16 * 16 * 16 * 16 * 15, fmt-thousands-separator: "_"), "-3_600_000") + assert.eq(strfmt("{:?}", 5555.0, fmt-thousands-separator: "_"), "5_555.0") + assert.eq(strfmt("{:e}", 5555.2, fmt-thousands-separator: "_", fmt-decimal-separator: "heap"), "5heap5552e3") + assert.eq(strfmt("{:010}", 5555.2, fmt-thousands-separator: "_", fmt-decimal-separator: "€"), "00_005_555€2") + assert.eq(strfmt("{:€>10}", 5555.2, fmt-thousands-separator: "_", fmt-decimal-separator: "€"), "€€€5_555€2") + assert.eq(strfmt("{:€>10}", 5555.2, fmt-thousands-separator: "€a", fmt-decimal-separator: "€"), "€€5€a555€2") + + // Test count + assert.eq(strfmt("{}", 10, fmt-thousands-count: 3, fmt-thousands-separator: "_"), "10") + assert.eq(strfmt("{}", 10, fmt-thousands-count: 1, fmt-thousands-separator: "_"), "1_0") + assert.eq(strfmt("{}", 1000, fmt-thousands-count: 2, fmt-thousands-separator: "_"), "10_00") + assert.eq(strfmt("{}", 10000000.3231, fmt-thousands-count: 2, fmt-thousands-separator: "_"), "10_00_00_00.3231") + assert.eq(strfmt("{}", float("nan"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "NaN") + assert.eq(strfmt("{:010}", -23003, fmt-thousands-count: 4, fmt-thousands-separator: "|"), "-0|0002|3003") + assert.eq(strfmt("{:#b}", 255, fmt-thousands-count: 1, fmt-thousands-separator: "_"), "0b1_1_1_1_1_1_1_1") + assert.eq(strfmt("{:#x}", -16 * 16 * 16 * 16 * 15, fmt-thousands-count: 2, fmt-thousands-separator: "_"), "-0xf_00_00") + assert.eq(strfmt("{:o}", -16 * 16 * 16 * 16 * 15, fmt-thousands-count: 4, fmt-thousands-separator: "_"), "-360_0000") + assert.eq(strfmt("{:05}", float("nan"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "00NaN") } // DOC TESTS #{