diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5a130ed --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# You may support oxifmt by sponsoring the account(s) below + +github: PgBiel diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml new file mode 100644 index 0000000..3504f46 --- /dev/null +++ b/.github/workflows/tests-ci.yml @@ -0,0 +1,44 @@ +name: Tests CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the branches below + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + # Allows one to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + 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] + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + - name: 📥 Setup Typst + uses: typst-community/setup-typst@v3 + id: setup-typst + with: + typst-version: ${{ matrix.typst-version }} + + - name: 🛠️ Compile test document (") ``` - **Inserting other types than numbers and strings** (for now, they will always use `repr()`, even without `{...:?}`, although that is more explicit): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("Values: {:?}, {1:?}, {stuff:?}", (test: 500), ("a", 5.1), stuff: [a]) #assert.eq(s, "Values: (test: 500), (\"a\", 5.1), [a]") ``` - **Padding to a certain width with characters:** Use `{:x<8}`, where `x` is the **character to pad with** (e.g. space or `_`, but can be anything), `<` is the **alignment of the original text** relative to the padding (can be `<` for left aligned (padding goes to the right), `>` for right aligned (padded to its left) and `^` for center aligned (padded at both left and right)), and `8` is the **desired total width** (padding will add enough characters to reach this width; if the replacement string already has this width, no padding will be added): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt -#let s = strfmt("Left5 {:_<5}, Right6 {:*>6}, Center10 {centered: ^10?}, Left3 {tleft:_<3}", "xx", 539, tleft: "okay", centered: [a]) -#assert.eq(s, "Left5 xx___, Right6 ***539, Center10 [a] , Left3 okay") +#let s = strfmt("Left5 {:-<5}, Right6 {:=>6}, Center10 {centered: ^10?}, Left3 {tleft:_<3}", "xx", 539, tleft: "okay", centered: [a]) +#assert.eq(s, "Left5 xx---, Right6 ===539, Center10 [a] , Left3 okay") // note how 'okay' didn't suffer any padding at all (it already had at least the desired total width). ``` - **Padding numbers with zeroes to the left:** It's a similar functionality to the above, however you write `{:08}` for 8 characters (for instance) - note that any characters in the number's representation matter for width (including sign, dot and decimal part): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("Left-padded7 numbers: {:07} {:07} {:07} {3:07}", 123, -344, 44224059, 45.32) #assert.eq(s, "Left-padded7 numbers: 0000123 -000344 44224059 0045.32") ``` - **Defining padding-to width using parameters, not literals:** If you want the desired replacement width (the `8` in `{:08}` or `{: ^8}`) to be passed via parameter (instead of being hardcoded into the format string), you can specify `parameter$` in place of the width, e.g. `{:02$}` to take it from the third positional parameter, or `{:a>banana$}` to take it from the parameter named `banana` - note that the chosen parameter **must be an integer** (desired total width): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("Padding depending on parameter: {0:02$} and {0:a>banana$}", 432, 0, 5, banana: 9) #assert.eq(s, "Padding depending on parameter: 00432 aaaaaa432") // widths 5 and 9 ``` - **Displaying `+` on positive numbers:** Just add a `+` at the "beginning", i.e., before the `#0` (if either is there), or after the custom fill and align (if it's there and not `0` - see [Grammar](#grammar) for the exact positioning), like so: -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("Some numbers: {:+} {:+08}; With fill and align: {:_<+8}; Negative (no-op): {neg:+}", 123, 456, 4444, neg: -435) #assert.eq(s, "Some numbers: +123 +0000456; With fill and align: +4444___; Negative (no-op): -435") @@ -147,32 +147,32 @@ Some examples: ``` - **Converting numbers to bases 2, 8 and 16:** Use one of the following specifier types (i.e., characters which always go at the very end of the format): `b` (binary), `o` (octal), `x` (lowercase hexadecimal) or `X` (uppercase hexadecimal). You can also add a `#` between `+` and `0` (see the exact position at the [Grammar](#grammar)) to display a **base prefix** before the number (i.e. `0b` for binary, `0o` for octal and `0x` for hexadecimal): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("Bases (10, 2, 8, 16(l), 16(U):) {0} {0:b} {0:o} {0:x} {0:X} | W/ prefixes and modifiers: {0:#b} {0:+#09o} {0:_>+#9X}", 124) #assert.eq(s, "Bases (10, 2, 8, 16(l), 16(U):) 124 1111100 174 7c 7C | W/ prefixes and modifiers: 0b1111100 +0o000174 ____+0x7C") ``` - **Picking float precision (right-extending with zeroes):** Add, at the end of the format (just before the spec type (such as `?`), if there's any), either `.precision` (hardcoded, e.g. `.8` for 8 decimal digits) or `.parameter$` (taking the precision value from the specified parameter, like with `width`): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("{0:.8} {0:.2$} {0:.potato$}", 1.234, 0, 2, potato: 5) #assert.eq(s, "1.23400000 1.23 1.23400") ``` - **Scientific notation:** Use `e` (lowercase) or `E` (uppercase) as specifier types (can be combined with precision): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("{0:e} {0:E} {0:+.9e} | {1:e} | {2:.4E}", 124.2312, 50, -0.02) #assert.eq(s, "1.242312e2 1.242312E2 +1.242312000e2 | 5e1 | -2.0000E-2") ``` - **Customizing the decimal separator on floats:** Just specify `fmt-decimal-separator: ","` (comma as an example): -```js -#import "@preview/oxifmt:0.2.0": strfmt +```typ +#import "@preview/oxifmt:0.2.1": strfmt #let s = strfmt("{0} {0:.6} {0:.5e}", 1.432, fmt-decimal-separator: ",") #assert.eq(s, "1,432 1,432000 1,43200e0") @@ -214,6 +214,12 @@ The tests succeeded if you received no error messages from the last command (ple ## Changelog +### v0.2.1 + +- Fixed formatting of UTF-8 strings. Before, strings with multi-byte UTF-8 codepoints would cause formatting inconsistencies or even crashes. ([Issue #6](https://github.com/PgBiel/typst-oxifmt/issues/6)) +- Fixed an inconsistency in negative number formatting. Now, it will always print a regular hyphen (e.g. '-2'), which is consistent with Rust's behavior; before, it would occasionally print a minus sign instead (as observed in a comment to [Issue #4](https://github.com/PgBiel/typst-oxifmt/issues/4)). +- Added compatibility with Typst 0.8.0's new type system. + ### v0.2.0 - The package's name is now `oxifmt`! diff --git a/oxifmt.typ b/oxifmt.typ index aba04be..1283f24 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -1,6 +1,13 @@ -// oxifmt v0.2.0 +// oxifmt v0.2.1 + +// For compatibility with pre-0.8.0 Typst types, which were strings +#let _int-type = type(0) +#let _float-type = type(5.5) +#let _str-type = type("") +#let _label-type = type() + #let _strfmt_formatparser(s) = { - if type(s) != "string" { + if type(s) != _str-type { panic("String format parsing internal error: String format parser given non-string.") } let result = () @@ -13,11 +20,10 @@ let last-was-lbracket = false // if the last character was an unescaped } let last-was-rbracket = false - let last-i = 0 // -- procedures -- - let write-format-span(i, result, current-fmt-span, current-fmt-name) = { - current-fmt-span.at(1) = i // end index + let write-format-span(last-i, result, current-fmt-span, current-fmt-name) = { + current-fmt-span.at(1) = last-i + 1 // end index result.push((format: (name: current-fmt-name, span: current-fmt-span))) current-fmt-span = none current-fmt-name = none @@ -36,14 +42,15 @@ } // -- parse loop -- - for (i, character) in codepoints.enumerate() { - last-i = i + let last-i = none + let i = 0 + for character in codepoints { if character == "{" { // double l-bracket = escape if last-was-lbracket { last-was-lbracket = false // escape {{ last-was-rbracket = false - if current-fmt-span.at(0) == i - 1 { + if current-fmt-span.at(0) == last-i { current-fmt-span = none // cancel this span current-fmt-name = none } @@ -53,13 +60,16 @@ current-fmt-name += character } else { // outside a span ({...} {{ <-) => emit an 'escaped' token - result.push((escape: (escaped: "{", span: (i - 1, i + 1)))) + result.push((escape: (escaped: "{", span: (last-i, i + 1)))) } + + last-i = i + i += 1 // '{' is ASCII, so 1 byte continue } if last-was-rbracket { // { ... }{ <--- ok, close the previous span - (result, current-fmt-span, current-fmt-name) = write-format-span(i, result, current-fmt-span, current-fmt-name) + (result, current-fmt-span, current-fmt-name) = write-format-span(last-i, result, current-fmt-span, current-fmt-name) last-was-rbracket = false } if current-fmt-span == none { @@ -75,8 +85,11 @@ if current-fmt-name != none { current-fmt-name += character } else { - result.push((escape: (escaped: "}", span: (i - 1, i + 1)))) + result.push((escape: (escaped: "}", span: (last-i, i + 1)))) } + + last-i = i + i += 1 // '}' is ASCII, so 1 byte continue } // delay closing the span to the next iteration @@ -84,7 +97,7 @@ last-was-rbracket = true } else { // { ... {A <--- non-escaped { inside larger {} - if last-was-lbracket and (current-fmt-span != none and current-fmt-span.at(0) != i - 1) { + if last-was-lbracket and (current-fmt-span != none and current-fmt-span.at(0) != last-i) { excessive-lbracket() } if last-was-rbracket { @@ -93,7 +106,7 @@ excessive-rbracket() } else { // { ... }A <--- ok, close the previous span - (result, current-fmt-span, current-fmt-name) = write-format-span(i, result, current-fmt-span, current-fmt-name) + (result, current-fmt-span, current-fmt-name) = write-format-span(last-i, result, current-fmt-span, current-fmt-name) } } // {abc <--- add character to the format name @@ -103,12 +116,15 @@ last-was-lbracket = false last-was-rbracket = false } + + last-i = i + i += character.len() // index must be in bytes, and a UTF-8 codepoint can have more than one byte } // { ... if current-fmt-span != none { if last-was-rbracket { // ... } <--- ok, close span - (result, current-fmt-span, current-fmt-name) = write-format-span(last-i + 1, result, current-fmt-span, current-fmt-name) + (result, current-fmt-span, current-fmt-name) = write-format-span(last-i, result, current-fmt-span, current-fmt-name) } else { // {abcd| <--- string ended with unclosed span missing-rbracket() @@ -124,7 +140,7 @@ let subparts = name.match(regex("^([^:]*)(?::(.*))?$")).captures let name = subparts.at(0) let extras = subparts.at(1) - let name = if type(name) != "string" { + let name = if type(name) != _str-type { name } else if name == "" { none @@ -137,11 +153,15 @@ } #let _strfmt_is-numeric-type(obj) = { - type(obj) in ("integer", "float") + type(obj) in (_int-type, _float-type) } #let _strfmt_stringify(obj) = { - if type(obj) in ("integer", "float", "label", "string") { + if type(obj) in (_int-type, _float-type) { + // Fix negative sign not being a hyphen + // for consistency with our rich formatting output + str(obj).replace("\u{2212}", "-") + } else if type(obj) in (_label-type, _str-type) { str(obj) } else { repr(obj) @@ -150,7 +170,7 @@ #let _strfmt_display-radix(num, radix, signed: true, lowercase: false) = { let num = int(num) - if type(radix) != "integer" or num == 0 or radix <= 1 { + if type(radix) != _int-type or num == 0 or radix <= 1 { return "0" } let sign = if num < 0 and signed { "-" } else { "" } @@ -208,7 +228,7 @@ let mantissa = f / calc.pow(10, exponent) let mantissa = _strfmt_with-precision(mantissa, precision) - mantissa + exponent-sign + str(exponent) + mantissa + exponent-sign + _strfmt_stringify(exponent) } // Parses {format:specslikethis}. @@ -266,7 +286,7 @@ parameter := argument '$' ) let arg = pos-replacements.at(i) assert( - type(arg) == "integer", + type(arg) == _int-type, message: "String formatter error: Attempted to use positional argument " + str(i) + " for " + spec-part-name + ", but it was a(n) '" + type(arg) + "', not an integer (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}')." ) @@ -282,7 +302,7 @@ parameter := argument '$' ) let arg = named-replacements.at(named) assert( - type(arg) == "integer", + type(arg) == _int-type, message: "String formatter error: Attempted to use named argument '" + named + "' for " + spec-part-name + ", but it was a(n) '" + type(arg) + "', not an integer (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}')." ) @@ -352,9 +372,9 @@ parameter := argument '$' 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) - } else if type(replacement) != "integer" and precision != none { + } else if type(replacement) != _int-type and precision != none { replacement = _strfmt_with-precision(replacement, precision) - } else if type(replacement) == "integer" and spectype in ("x", "X", "b", "o", "x?", "X?") { + } 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") @@ -453,7 +473,7 @@ parameter := argument '$' } replace-by = num-replacements.at(fmt-index) unnamed-format-index += 1 - } else if type(name) == "integer" { + } else if type(name) == _int-type { let fmt-index = name let amount-pos-replacements = num-replacements.len() if amount-pos-replacements == 0 { diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index 3a91788..27862c1 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -46,6 +46,17 @@ // test custom decimal separators (III) - ensure we can fetch it from inside assert.eq(strfmt("5{fmt-decimal-separator}6", fmt-decimal-separator: "|"), "5|6") } +// Issue #6: UTF-8 +#{ + // shouldn't crash + assert.eq(strfmt("Hello € {}", "man"), "Hello € man") + + // should replace at the appropriate location + assert.eq(strfmt("Bank: {company-bank-name} € IBAN: {company-bank-iban}", company-bank-name: "FAKE", company-bank-iban: "Broken stuff"), "Bank: FAKE € IBAN: Broken stuff") + + // test grapheme clusters + assert.eq(strfmt("Ĺo͂řȩ{}m̅", 5.5), "Ĺo͂řȩ5.5m̅") +} // DOC TESTS #{ // --- Usage --- diff --git a/typst.toml b/typst.toml index 59286eb..6f680c1 100644 --- a/typst.toml +++ b/typst.toml @@ -1,6 +1,6 @@ [package] name = "oxifmt" -version = "0.2.0" +version = "0.2.1" authors = ["PgBiel "] license = "MIT-0" description = "Convenient Rust-like string formatting in Typst"