From 735e2ca1c5e3d5556b672d110e93c405ee7aff45 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 1 Oct 2024 00:24:31 -0300 Subject: [PATCH 01/17] change license to MIT/Apache --- LICENSE | 16 ---- LICENSE-APACHE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE-MIT | 21 ++++++ README.md | 2 +- typst.toml | 2 +- 5 files changed, 224 insertions(+), 18 deletions(-) delete mode 100644 LICENSE create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a9cb23f..0000000 --- a/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT No Attribution - -Copyright (c) 2023 Pg Biel - -Permission is hereby granted, free of charge, to any person obtaining a copy of this -software and associated documentation files (the "Software"), to deal in the Software -without restriction, including without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..71b8799 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 PgBiel + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..869be70 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 PgBiel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4782894..ac10e3c 100644 --- a/README.md +++ b/README.md @@ -234,4 +234,4 @@ The tests succeeded if you received no error messages from the last command (ple ## License -MIT-0 license (see the `LICENSE` file). +Licensed under MIT or Apache-2.0, at your option. diff --git a/typst.toml b/typst.toml index 6f680c1..13fcdba 100644 --- a/typst.toml +++ b/typst.toml @@ -2,7 +2,7 @@ name = "oxifmt" version = "0.2.1" authors = ["PgBiel "] -license = "MIT-0" +license = "MIT OR Apache-2.0" description = "Convenient Rust-like string formatting in Typst" entrypoint = "oxifmt.typ" repository = "https://github.com/PgBiel/typst-oxifmt" From d660b1130fe4467e5fe494652f8eb240f1cd379a Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Oct 2024 23:49:55 -0300 Subject: [PATCH 02/17] Add thousand separator options (#12) * experiment with thousand sep * add fmt-thousands-count and tests, fix bugs * bump minimum typst version * test version compat * polyfill array chunks * fix polyfill version * add Typst v0.10.0 to tests * workaround for repr inconsistency * fix utf-8 bugs * fix simple negative numbers * use array.rev() for thousands separator stuff * handle NaN * update this test * allow typst 0.7.0 * use codepoints for thousand sep * optimize arr-chunks polyfill because why not * test more recent typst versions * move is-nan --- .github/workflows/tests-ci.yml | 19 ++--- oxifmt.typ | 147 +++++++++++++++++++++++++++------ tests/strfmt-tests.typ | 40 +++++++++ 3 files changed, 171 insertions(+), 35 deletions(-) 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 #{ From e78fc63463bc4f0e8cad4cab17f6c7fee79381ef Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Oct 2024 23:28:03 -0300 Subject: [PATCH 03/17] address more float edge cases --- oxifmt.typ | 12 +++++++----- tests/strfmt-tests.typ | 13 +++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 4ca2c66..521433f 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -229,7 +229,7 @@ } #let _strfmt_with-precision(num, precision) = { - if precision == none or type(num) == _float-type and (_float-is-nan(num) or _float-is-infinite(num)) { + if precision == none { return _strfmt_stringify(num) } let result = _strfmt_stringify(calc.round(float(num), digits: calc.min(50, precision))) @@ -290,10 +290,11 @@ parameter := argument '$' if is-numeric { let is-nan = type(replacement) == _float-type and _float-is-nan(replacement) + let is-inf = type(replacement) == _float-type and _float-is-infinite(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)) { + if fmt-thousands-separator != "" and not is-nan and not is-inf { integral = _arr-chunks(integral.codepoints().rev(), fmt-thousands-count) .join(fmt-thousands-separator.codepoints().rev()) .rev() @@ -401,6 +402,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) + let is-inf = type(replacement) == _float-type and _float-is-infinite(replacement) if zero { // disable fill, we will be prefixing with zeroes if necessary fill = none @@ -431,12 +433,12 @@ parameter := argument '$' let fractional = () let exponent-suffix = "" - if spectype in ("e", "E") { + if spectype in ("e", "E") and not is-nan and not is-inf { let exponent-sign = if spectype == "E" { "E" } else { "e" } 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 { + } else if type(replacement) != _int-type and precision != none and not is-nan and not is-inf { 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?") { @@ -476,7 +478,7 @@ parameter := argument '$' } // 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)) { + if fmt-thousands-separator != "" and not is-nan and not is-inf { integral = _arr-chunks(integral.codepoints().rev(), fmt-thousands-count) .join(fmt-thousands-separator.codepoints().rev()) .rev() diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index 03d8309..b0b6a0b 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -61,6 +61,15 @@ assert.eq(strfmt("{:€>15}", "Ĺo͂řȩ5.5m̅"), "€€€€€Ĺo͂řȩ5.5m̅") assert.eq(strfmt("{:€>10}", "abc€d"), "€€€€€abc€d") } +// Float edge cases +#{ + assert.eq(strfmt("{}", float("nan")), "NaN") + assert.eq(strfmt("{:05.10}", float("nan")), "00NaN") + assert.eq(strfmt("{:05e}", float("nan")), "00NaN") + assert.eq(strfmt("{:05e}", float("inf")), "00inf") + assert.eq(strfmt("{:+05e}", float("inf")), "+0inf") + assert.eq(strfmt("{:05e}", -float("inf")), "-0inf") +} // Issue #5: Thousands #{ // Test separator @@ -91,11 +100,15 @@ 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("{}", float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "inf") + assert.eq(strfmt("{}", -float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "-inf") 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") + assert.eq(strfmt("{:05}", float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "00inf") + assert.eq(strfmt("{:05}", -float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "-0inf") } // DOC TESTS #{ From c9d950ea6f0b0d1af369f5c65ec6109ff26cfafb Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Oct 2024 22:43:40 -0300 Subject: [PATCH 04/17] optimize string split --- oxifmt.typ | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 521433f..639efc6 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -171,19 +171,17 @@ #let _strfmt_parse-fmt-name(name) = { // {a:b} => separate 'a' from 'b' // (also accepts {a}, {}, {0}, {:...}) - let subparts = name.match(regex("^([^:]*)(?::(.*))?$")).captures - let name = subparts.at(0) - let extras = subparts.at(1) + let (name, ..extras) = name.split(":") let name = if type(name) != _str-type { name } else if name == "" { none - } else if regex("^\\d+$") in name { + } else if name.codepoints().all(x => x == "0" or x == "1" or x == "2" or x == "3" or x == "4" or x == "5" or x == "6" or x == "7" or x == "8" or x == "9") { int(name) } else { name } - (name, extras) + (name, extras.join()) } #let _strfmt_is-numeric-type(obj) = { From 2a4094fe06ad65c1b944cd8a72940ca9b539dae8 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Tue, 8 Oct 2024 23:58:03 -0300 Subject: [PATCH 05/17] fix inf string in 0.12.0 --- oxifmt.typ | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 639efc6..b712148 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -189,9 +189,16 @@ } #let _strfmt_stringify(obj) = { - if type(obj) in (_int-type, _float-type) { - // Fix negative sign not being a hyphen - // for consistency with our rich formatting output + if type(obj) == _float-type { + if _float-is-infinite(obj) { + // Fix 0.12.0 inf string inconsistency + if obj < 0 { "-" } else { "" } + "inf" + } else { + // Fix negative sign not being a hyphen + // for consistency with our rich formatting output + str(obj).replace("\u{2212}", "-") + } + } else if type(obj) == _int-type { str(obj).replace("\u{2212}", "-") } else if type(obj) in (_label-type, _str-type) { str(obj) From ffd108760b04c8ea7909911828fb31030a04ed2d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Thu, 17 Oct 2024 01:43:27 -0300 Subject: [PATCH 06/17] bump ci to typst 0.12.0-rc2 --- .github/workflows/tests-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index b4cdf8a..b04a1ca 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -22,9 +22,9 @@ jobs: # Test for the following Typst versions # 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) + # 0.11.0, 0.11.1, 0.12-rc2 (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] + [v0.7.0, v0.8.0, v0.9.0, v0.10.0, v0.11.0, v0.11.1, v0.12.0-rc2] # Steps represent a sequence of tasks that will be executed as part of the job steps: From 8a097d238430972832603d469467f75c6f2a9187 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:48:32 -0300 Subject: [PATCH 07/17] bump ci to typst 0.13.0-rc1 --- .github/workflows/tests-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index b04a1ca..0ddea19 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -24,7 +24,7 @@ jobs: # 0.9.0 (first version with version checks), 0.10.0, # 0.11.0, 0.11.1, 0.12-rc2 (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-rc2] + [v0.7.0, v0.8.0, v0.9.0, v0.10.0, v0.11.0, v0.11.1, v0.12.0, v0.13.0-rc1] # Steps represent a sequence of tasks that will be executed as part of the job steps: From db4c8a4b93b18cdc7029f916c3503418efd636a4 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:54:01 -0300 Subject: [PATCH 08/17] fix type strings for 0.13.0 --- oxifmt.typ | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index b712148..18a2947 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -348,7 +348,7 @@ parameter := argument '$' let arg = pos-replacements.at(i) assert( 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("}", "}}") + "}')." + message: "String formatter error: Attempted to use positional argument " + str(i) + " for " + spec-part-name + ", but it was a(n) '" + str(type(arg)) + "', not an integer (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}')." ) int(arg) @@ -364,7 +364,7 @@ parameter := argument '$' let arg = named-replacements.at(named) assert( 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("}", "}}") + "}')." + message: "String formatter error: Attempted to use named argument '" + named + "' for " + spec-part-name + ", but it was a(n) '" + str(type(arg)) + "', not an integer (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}')." ) int(arg) @@ -545,7 +545,7 @@ parameter := argument '$' assert( type(fmt-thousands-count) == _int-type, - message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + type(fmt-thousands-count) + "' instead." + message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + str(type(fmt-thousands-count)) + "' instead." ) assert( fmt-thousands-count > 0, @@ -553,7 +553,7 @@ parameter := argument '$' ) 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." + message: "String formatter error: 'fmt-thousands-separator' must be a string (or empty string, \"\", to disable), got '" + str(type(fmt-thousands-separator)) + "' instead." ) let parts = () From d9f56b26d1863ffc2bffd7f8efa075b3f9eb742d Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 18:44:08 -0300 Subject: [PATCH 09/17] Add 0.12.0 decimal support (#14) * initial decimal attempt * proper exp syntax support for decimals * support signed zero * add more tests --- oxifmt.typ | 92 +++++++++++++++++++++++++++++++++++++----- tests/strfmt-tests.typ | 28 ++++++++++++- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 18a2947..be8c2ce 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -6,9 +6,13 @@ #let _str-type = type("") #let _label-type = type() +#let _minus-sign = "\u{2212}" #let using-080 = type(type(5)) != _str-type -#let using-090 = using-080 and str(-1).codepoints().first() == "\u{2212}" +#let using-090 = using-080 and str(-1).codepoints().first() == _minus-sign #let using-0110 = using-090 and sys.version >= version(0, 11, 0) +#let using-0120 = using-090 and sys.version >= version(0, 12, 0) + +#let _decimal = if using-0120 { decimal } else { none } #let _arr-chunks = if using-0110 { array.chunks @@ -185,7 +189,7 @@ } #let _strfmt_is-numeric-type(obj) = { - type(obj) in (_int-type, _float-type) + type(obj) in (_int-type, _float-type, _decimal) } #let _strfmt_stringify(obj) = { @@ -200,7 +204,7 @@ } } else if type(obj) == _int-type { str(obj).replace("\u{2212}", "-") - } else if type(obj) in (_label-type, _str-type) { + } else if type(obj) in (_label-type, _str-type, _decimal) { str(obj) } else { repr(obj) @@ -212,6 +216,8 @@ if type(radix) != _int-type or num == 0 or radix <= 1 { return "0" } + + // Note: only integers are accepted here, so no need to check for decimal signed zero let sign = if num < 0 and signed { "-" } else { "" } let num = calc.abs(num) let radix = calc.min(radix, 16) @@ -237,7 +243,7 @@ if precision == none { return _strfmt_stringify(num) } - let result = _strfmt_stringify(calc.round(float(num), digits: calc.min(50, precision))) + let result = _strfmt_stringify(calc.round(if type(num) == _decimal { num } else { float(num) }, digits: calc.min(50, precision))) let digits-match = result.match(regex("^\\d+\\.(\\d+)$")) let digits-len-diff = 0 if digits-match != none and digits-match.captures.len() > 0 { @@ -260,10 +266,56 @@ result } -#let _strfmt_exp-format(num, exponent-sign: "e", base: 10, precision: none) = { +#let _strfmt_exp-format(num, exponent-sign: "e", precision: none) = { assert(_strfmt_is-numeric-type(num), message: "String formatter internal error: Cannot convert '" + repr(num) + "' to a number to obtain its scientific notation representation.") + + if type(num) == _decimal { + // Normalize signed zero + let num = if num == 0 { _decimal("0") } else { num } + let (integral, ..fractional) = str(num).split(".") + // Normalize decimals with larger scales than is needed + let fractional = fractional.sum(default: "").trim("0", at: end) + let (integral, fractional, exponent) = if num > -1 and num < 1 and fractional != "" { + let first-non-zero = fractional.position("1") + if first-non-zero == none { first-non-zero = fractional.position("2") } + if first-non-zero == none { first-non-zero = fractional.position("3") } + if first-non-zero == none { first-non-zero = fractional.position("4") } + if first-non-zero == none { first-non-zero = fractional.position("5") } + if first-non-zero == none { first-non-zero = fractional.position("6") } + if first-non-zero == none { first-non-zero = fractional.position("7") } + if first-non-zero == none { first-non-zero = fractional.position("8") } + if first-non-zero == none { first-non-zero = fractional.position("9") } + assert(first-non-zero != none, message: "String formatter internal error: expected non-zero fractional digit") + + // Integral part is zero + // Convert 0.00012345 -> 1.2345 + // Position of first non-zero is the amount of zeroes - 1 + // (e.g. above, position of 1 is 3 => 3 zeroes, + // so exponent is -3 - 1 = -4) + ( + fractional.at(first-non-zero), + fractional.slice(first-non-zero + 1), + -first-non-zero - 1 + ) + } else { + // Number has non-zero integral part, or is zero + // Convert 12345.6789 -> 1.23456789 + // Exponent is integral digits - 1 + ( + integral.at(0), + integral.slice(1) + fractional, + integral.len() - 1 + ) + } + return ( + // mantissa + integral + if fractional != "" { "." + fractional } else { "" }, + exponent-sign + _strfmt_stringify(exponent) + ) + } + let f = float(num) - let exponent = if f == 0 { 1 } else { calc.floor(calc.log(calc.abs(f), base: base)) } + let exponent = if f == 0 { 1 } else { calc.floor(calc.log(calc.abs(f), base: 10)) } let mantissa = f / calc.pow(10, exponent) let mantissa = _strfmt_with-precision(mantissa, precision) @@ -297,7 +349,17 @@ parameter := argument '$' let is-nan = type(replacement) == _float-type and _float-is-nan(replacement) let is-inf = type(replacement) == _float-type and _float-is-infinite(replacement) let string-replacement = _strfmt_stringify(calc.abs(replacement)) - let sign = if not is-nan and replacement < 0 { "-" } else { "" } + let sign = if ( + not is-nan and replacement < 0 + or replacement == 0 and type(replacement) == _decimal and ( + // Preserve signed zero decimal + "-" in str(replacement) or _minus-sign in str(replacement) + ) + ) { + "-" + } else { + "" + } let (integral, ..fractional) = string-replacement.split(".") if fmt-thousands-separator != "" and not is-nan and not is-inf { integral = _arr-chunks(integral.codepoints().rev(), fmt-thousands-count) @@ -404,8 +466,7 @@ parameter := argument '$' spec-error() } - let is-numeric = _strfmt_is-numeric-type(replacement) - if is-numeric { + if _strfmt_is-numeric-type(replacement) { let is-nan = type(replacement) == _float-type and _float-is-nan(replacement) let is-inf = type(replacement) == _float-type and _float-is-infinite(replacement) if zero { @@ -420,8 +481,17 @@ parameter := argument '$' align = right } - // if + is specified, + will appear before all numbers >= 0. - if sign == "+" and not is-nan and replacement >= 0 { + if replacement == 0 and type(replacement) == _decimal { + // Preserve signed zero. + if "-" in str(replacement) or _minus-sign in str(replacement) { + sign = "-" + } else if sign == "+" { + sign = "+" + } else { + sign = "" + } + } else if sign == "+" and not is-nan and replacement >= 0 { + // if + is specified, + will appear before all numbers >= 0. sign = "+" } else if not is-nan and replacement < 0 { sign = "-" diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index b0b6a0b..e78d67d 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -1,4 +1,4 @@ -#import "../oxifmt.typ": strfmt +#import "../oxifmt.typ": strfmt, using-0120 #{ // test basics (sequential args, named args, pos args) @@ -110,6 +110,32 @@ assert.eq(strfmt("{:05}", float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "00inf") assert.eq(strfmt("{:05}", -float("inf"), fmt-thousands-count: 2, fmt-thousands-separator: "_"), "-0inf") } +// Issue #11: Decimals +#if using-0120 { + assert.eq(strfmt("{}", decimal("1223.4350320000")), "1223.4350320000") + assert.eq(strfmt("{}", decimal("1223.435032")), "1223.435032") + assert.eq(strfmt("{}", decimal("-1223.435032")), "-1223.435032") + assert.eq(strfmt("{}", decimal("-1223.435032"), fmt-thousands-separator: "_"), "-1_223.435032") + assert.eq(strfmt("{}", decimal("0")), "0") + assert.eq(strfmt("{}", -decimal("0")), "-0") + assert.eq(strfmt("{:+}", decimal("0")), "+0") + assert.eq(strfmt("{:+}", -decimal("0")), "-0") + assert.eq(strfmt("{:+09}", decimal("1231422")), "+01231422") + assert.eq(strfmt("{:+09}", decimal("1234.5")), "+001234.5") + assert.eq(strfmt("{:+09}", decimal("1234.5"), fmt-thousands-separator: "_"), "+001_234.5") + assert.eq(strfmt("{:+09}", decimal("0.0001")), "+000.0001") + assert.eq(strfmt("{:011e}", decimal("1231422")), "01.231422e6") + assert.eq(strfmt("{:011e}", -decimal("1234.5")), "-001.2345e3") + assert.eq(strfmt("{:011e}", -decimal("1234.50000")), "-001.2345e3") + assert.eq(strfmt("{:011e}", -decimal("0.00012345")), "-01.2345e-4") + assert.eq(strfmt("{:011e}", decimal("0.0001")), "00000001e-4") + assert.eq(strfmt("{:e}", decimal("0.0001")), "1e-4") + assert.eq(strfmt("{:e}", decimal("0")), "0e0") + assert.eq(strfmt("{:e}", -decimal("0")), "-0e0") + assert.eq(strfmt("{:e}", decimal("132423")), "1.32423e5") + assert.eq(strfmt("{:e}", decimal("-132423")), "-1.32423e5") + assert.eq(strfmt("{:011.5}", decimal("1234.5")), "01234.50000") +} // DOC TESTS #{ // --- Usage --- From 07c94c9fcd96bf1cd5118fd4f33d20f807a4db76 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 18:46:41 -0300 Subject: [PATCH 10/17] add 0.13.1 to ci --- .github/workflows/tests-ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index 0ddea19..8763328 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -22,9 +22,19 @@ jobs: # Test for the following Typst versions # 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-rc2 (latest supported) + # 0.11.0, 0.11.1, 0.13.1 (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, v0.13.0-rc1] + [ + v0.7.0, + v0.8.0, + v0.9.0, + v0.10.0, + v0.11.0, + v0.11.1, + v0.12.0, + v0.13.0, + v0.13.1, + ] # Steps represent a sequence of tasks that will be executed as part of the job steps: From 6722193e5f67bc5270536fd93b683043b8c7c088 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 19:07:52 -0300 Subject: [PATCH 11/17] Fix large numbers (#18) * fix large numbers * more cross-compatible infinite float --- oxifmt.typ | 2 +- tests/strfmt-tests.typ | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/oxifmt.typ b/oxifmt.typ index be8c2ce..6b8d9e7 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -316,7 +316,7 @@ let f = float(num) let exponent = if f == 0 { 1 } else { calc.floor(calc.log(calc.abs(f), base: 10)) } - let mantissa = f / calc.pow(10, exponent) + let mantissa = f / calc.pow(10.0, exponent) let mantissa = _strfmt_with-precision(mantissa, precision) (mantissa, exponent-sign + _strfmt_stringify(exponent)) diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index e78d67d..6295c45 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -136,6 +136,23 @@ assert.eq(strfmt("{:e}", decimal("-132423")), "-1.32423e5") assert.eq(strfmt("{:011.5}", decimal("1234.5")), "01234.50000") } +// Issue #16: large numbers +#{ + assert.eq(strfmt("{0:e}",10000000000000000000.0), "1e19") + assert.eq(strfmt("{0:e}",10000000000000000000), "1e19") + assert.eq(strfmt("{0:e}",float("inf")), "inf") + assert.eq(strfmt("{0:e}",float("-inf")), "-inf") + assert.eq(strfmt("{0:e}",float("nan")), "NaN") + assert.eq(strfmt("{0:e}",1e50), "1e50") + assert.eq(strfmt("{0:e}",1e-50), "1.0000000000000002e-50") + assert.eq(strfmt("{0:.0e}",1e-50), "1e-50") + assert.eq(strfmt("{0:e}",1e300), "0.9999999999999994e300") + assert.eq(strfmt("{0:.0e}",1e300), "1e300") + assert.eq(strfmt("{0:.0e}",1e-300), "1e-300") + assert.eq(strfmt("{0:e}",2.2250738585072014e-308), "2.2250738585072027e-308") + assert.eq(strfmt("{0:e}",1.7976931348623157e+308), "1.7976931348623146e308") + assert.eq(strfmt("{0:e}",-1.7976931348623157e+308), "-1.7976931348623146e308") +} // DOC TESTS #{ // --- Usage --- From 573e3a55b773cb16bec37165d9dd147ff69aa3f1 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 19:18:21 -0300 Subject: [PATCH 12/17] Reserve `fmt-` prefix for options (#19) --- oxifmt.typ | 18 +++++++++++++++--- tests/strfmt-tests.typ | 3 --- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 6b8d9e7..c1be2b0 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -609,9 +609,21 @@ parameter := argument '$' let num-replacements = replacements.pos() 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 { "" } + let fmt-decimal-separator = auto + let fmt-thousands-count = 3 + let fmt-thousands-separator = "" + + for (name, value) in named-replacements { + if name == "fmt-decimal-separator" { + fmt-decimal-separator = named-replacements.remove(name) + } else if name == "fmt-thousands-count" { + fmt-thousands-count = named-replacements.remove(name) + } else if name == "fmt-thousands-separator" { + fmt-thousands-separator = named-replacements.remove(name) + } else if name.starts-with("fmt-") { + assert(false, message: "String formatter error: unknown format option '" + name + "'. Keys prefixed with 'fmt-' are reserved for future oxifmt options. Please use a different key name.") + } + } assert( type(fmt-thousands-count) == _int-type, diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index 6295c45..b02503a 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -42,9 +42,6 @@ // test custom decimal separators (II) - weird values assert.eq(strfmt("{}; {:015e}; {}; {}; {:?}", 1.532, 45000, -5.6, "a.b", "c.d", fmt-decimal-separator: (a: 5)), "1(a: 5)532; 000004(a: 5)5e4; -5(a: 5)6; a.b; \"c.d\"") - - // 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 #{ From f8a2c8ec511617f1afa475f5ca6d1f96fb2eb3f0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 19:24:43 -0300 Subject: [PATCH 13/17] use named args for fmt- options --- oxifmt.typ | 33 +++++++++++++++++---------------- tests/strfmt-tests.typ | 2 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index c1be2b0..8300d5e 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -603,28 +603,23 @@ parameter := argument '$' replacement } -#let strfmt(format, ..replacements) = { +#let strfmt( + format, + ..replacements, + fmt-decimal-separator: auto, + fmt-thousands-count: 3, + fmt-thousands-separator: "", +) = { if format == "" { return "" } let formats = _strfmt_formatparser(format) let num-replacements = replacements.pos() let named-replacements = replacements.named() let unnamed-format-index = 0 - let fmt-decimal-separator = auto - let fmt-thousands-count = 3 - let fmt-thousands-separator = "" - - for (name, value) in named-replacements { - if name == "fmt-decimal-separator" { - fmt-decimal-separator = named-replacements.remove(name) - } else if name == "fmt-thousands-count" { - fmt-thousands-count = named-replacements.remove(name) - } else if name == "fmt-thousands-separator" { - fmt-thousands-separator = named-replacements.remove(name) - } else if name.starts-with("fmt-") { - assert(false, message: "String formatter error: unknown format option '" + name + "'. Keys prefixed with 'fmt-' are reserved for future oxifmt options. Please use a different key name.") - } - } + assert( + fmt-decimal-separator == auto or type(fmt-decimal-separator) == str, + message: "String formatter error: 'fmt-decimal-separator' must be a string, got '" + str(type(fmt-decimal-separator)) + "' instead." + ) assert( type(fmt-thousands-count) == _int-type, message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + str(type(fmt-thousands-count)) + "' instead." @@ -638,6 +633,12 @@ parameter := argument '$' message: "String formatter error: 'fmt-thousands-separator' must be a string (or empty string, \"\", to disable), got '" + str(type(fmt-thousands-separator)) + "' instead." ) + for (name, _) in named-replacements { + if name.starts-with("fmt-") { + assert(false, message: "String formatter error: unknown format option '" + name + "'. Keys prefixed with 'fmt-' are reserved for future oxifmt options. Please use a different key name.") + } + } + let parts = () let last-span-end = 0 for f in formats { diff --git a/tests/strfmt-tests.typ b/tests/strfmt-tests.typ index b02503a..773e880 100644 --- a/tests/strfmt-tests.typ +++ b/tests/strfmt-tests.typ @@ -41,7 +41,7 @@ assert.eq(strfmt("{}; {:07e}; {}; {}; {:?}", 1.532, 45000, -5.6, "a.b", "c.d", fmt-decimal-separator: ","), "1,532; 004,5e4; -5,6; a.b; \"c.d\"") // test custom decimal separators (II) - weird values - assert.eq(strfmt("{}; {:015e}; {}; {}; {:?}", 1.532, 45000, -5.6, "a.b", "c.d", fmt-decimal-separator: (a: 5)), "1(a: 5)532; 000004(a: 5)5e4; -5(a: 5)6; a.b; \"c.d\"") + assert.eq(strfmt("{}; {:015e}; {}; {}; {:?}", 1.532, 45000, -5.6, "a.b", "c.d", fmt-decimal-separator: repr((a: 5))), "1(a: 5)532; 000004(a: 5)5e4; -5(a: 5)6; a.b; \"c.d\"") } // Issue #6: UTF-8 #{ From f1b9edc23fa85a4b8abb77f32e45338322653e97 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 19:27:20 -0300 Subject: [PATCH 14/17] proper fmt- option type checking --- oxifmt.typ | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/oxifmt.typ b/oxifmt.typ index 8300d5e..e85639a 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -616,22 +616,33 @@ parameter := argument '$' let named-replacements = replacements.named() let unnamed-format-index = 0 - assert( - fmt-decimal-separator == auto or type(fmt-decimal-separator) == str, - message: "String formatter error: 'fmt-decimal-separator' must be a string, got '" + str(type(fmt-decimal-separator)) + "' instead." - ) - assert( - type(fmt-thousands-count) == _int-type, - message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + str(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 '" + str(type(fmt-thousands-separator)) + "' instead." - ) + if fmt-decimal-separator != auto and type(fmt-decimal-separator) != str { + assert( + false, + message: "String formatter error: 'fmt-decimal-separator' must be a string or 'auto', got '" + str(type(fmt-decimal-separator)) + "' instead." + ) + } + + if type(fmt-thousands-count) != _int-type { + assert( + false, + message: "String formatter error: 'fmt-thousands-count' must be an integer, got '" + str(type(fmt-thousands-count)) + "' instead." + ) + } + + if fmt-thousands-count <= 0 { + assert( + false, + message: "String formatter error: 'fmt-thousands-count' must be a positive integer, got " + str(fmt-thousands-count) + " instead." + ) + } + + if type(fmt-thousands-separator) != _str-type { + assert( + false, + message: "String formatter error: 'fmt-thousands-separator' must be a string (or empty string, \"\", to disable), got '" + str(type(fmt-thousands-separator)) + "' instead." + ) + } for (name, _) in named-replacements { if name.starts-with("fmt-") { From f3ec9bfdac7ac4d182c4a2fa5079fcc5b349eab0 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 19:28:55 -0300 Subject: [PATCH 15/17] use _str-type --- oxifmt.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxifmt.typ b/oxifmt.typ index e85639a..cf4928e 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -616,7 +616,7 @@ parameter := argument '$' let named-replacements = replacements.named() let unnamed-format-index = 0 - if fmt-decimal-separator != auto and type(fmt-decimal-separator) != str { + if fmt-decimal-separator != auto and type(fmt-decimal-separator) != _str-type { assert( false, message: "String formatter error: 'fmt-decimal-separator' must be a string or 'auto', got '" + str(type(fmt-decimal-separator)) + "' instead." From 05aaa3030c75d0f9c905f58393dcb343e173b3e2 Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Sun, 25 May 2025 22:39:57 -0300 Subject: [PATCH 16/17] fix typo --- oxifmt.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxifmt.typ b/oxifmt.typ index cf4928e..c12882e 100644 --- a/oxifmt.typ +++ b/oxifmt.typ @@ -436,7 +436,7 @@ parameter := argument '$' } if precision-lit == "*" { - panic("String formater error: Precision specification of type `.*` is not supported yet (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}'). Try specifying your desired precision directly on the format spec, e.g. `.5`, or through some argument, e.g. `.name$` to take it from the 'name' named argument.") + panic("String formatter error: Precision specification of type `.*` is not supported yet (from '{" + fullname.replace("{", "{{").replace("}", "}}") + "}'). Try specifying your desired precision directly on the format spec, e.g. `.5`, or through some argument, e.g. `.name$` to take it from the 'name' named argument.") } let align = if align == "" { From c5a40664534f68cd26371170a276a31a012e048b Mon Sep 17 00:00:00 2001 From: PgBiel <9021226+PgBiel@users.noreply.github.com> Date: Mon, 26 May 2025 09:49:49 -0300 Subject: [PATCH 17/17] Release 0.3.0 (#20) --- LICENSE | 1 + README.md | 101 +++++++++++++++++++++++++++++++++-------- bin/package.sh | 17 +++++++ tests/strfmt-tests.typ | 47 +++++++++++++++++++ typst.toml | 2 +- 5 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 LICENSE create mode 100755 bin/package.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..03f646f --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +MIT OR Apache-2.0 diff --git a/README.md b/README.md index ac10e3c..c52e57f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,44 @@ -# typst-oxifmt (v0.2.1) +# typst-oxifmt (v0.3.0) -A Typst library that brings convenient string formatting and interpolation through the `strfmt` function. Its syntax is taken directly from Rust's `format!` syntax, so feel free to read its page for more information (https://doc.rust-lang.org/std/fmt/); however, this README should have enough information and examples for all expected uses of the library. Only a few things aren't supported from the Rust syntax, such as the `p` (pointer) format type, or the `.*` precision specifier. +A Typst library that brings convenient string formatting and interpolation through the `strfmt` function. Its syntax is taken directly from Rust's `format!` syntax, so feel free to read its page for more information (https://doc.rust-lang.org/std/fmt/); however, this README should have enough information and examples for all expected uses of the library. Only a few things aren't supported from the Rust syntax, such as the `p` (pointer) format type, or the `.*` precision specifier. Check out the ["Examples" section](#examples) for more. -A few extras (beyond the Rust-like syntax) will be added over time, though (feel free to drop suggestions at the repository: https://github.com/PgBiel/typst-oxifmt). The first "extra" so far is the `fmt-decimal-separator: "string"` parameter, which lets you customize the decimal separator for decimal numbers (floats) inserted into strings. E.g. `strfmt("Result: {}", 5.8, fmt-decimal-separator: ",")` will return the string `"Result: 5,8"` (comma instead of dot). See more below. +A few extras (beyond the Rust-like syntax) will be added over time, though (feel free to drop suggestions at the repository: https://github.com/PgBiel/typst-oxifmt). The first "extra" so far is the `fmt-decimal-separator: "string"` parameter, which lets you customize the decimal separator for decimal numbers (floats) inserted into strings. E.g. `strfmt("Result: {}", 5.8, fmt-decimal-separator: ",")` will return the string `"Result: 5,8"` (comma instead of dot). We also provide thousands separator support with `fmt-thousands-separator: "_"` for example. See more at ["Custom options"](#custom-options). -**Compatible with:** [Typst](https://github.com/typst/typst) v0.4.0+ +**Compatible with:** [Typst](https://github.com/typst/typst) v0.7.0+ + +## Quick examples + +```typ +#import "@preview/oxifmt:0.3.0": strfmt + +// "User John has 10 apples." +#strfmt("User {} has {} apples.", "John", 10) + +// "if exp > 100 { true }" +#strfmt("if {var} > {num} {{ true }}", var: "exp", num: 100) + +// "1.10e2 meters" +#strfmt("{:.2e} meters", 110.0) + +// "20_000 players have more than +002,300 points." +#strfmt( + "{} players have more than {:+08.3} points.", + 20000, + 2.3, + fmt-decimal-separator: ",", + fmt-thousands-separator: "_" +) + +// "The byte value is 0x8C or 10001100" +#strfmt("The byte value is {:#02X} or {0:08b}", 140) +``` ## Table of Contents - [Usage](#usage) - [Formatting options](#formatting-options) - [Examples](#examples) + - [Custom options](#custom-options) - [Grammar](#grammar) - [Issues and Contributing](#issues-and-contributing) - [Testing](#testing) @@ -22,7 +50,7 @@ A few extras (beyond the Rust-like syntax) will be added over time, though (feel You can use this library through Typst's package manager (for Typst v0.6.0+): ```typ -#import "@preview/oxifmt:0.2.1": strfmt +#import "@preview/oxifmt:0.3.0": strfmt ``` For older Typst versions, download the `oxifmt.typ` file either from Releases or directly from the repository. Then, move it to your project's folder, and write at the top of your Typst file(s): @@ -36,7 +64,7 @@ Doing the above will give you access to the main function provided by this libra Its syntax is almost identical to Rust's `format!` (as specified here: https://doc.rust-lang.org/std/fmt/). You can escape formats by duplicating braces (`{{` and `}}` become `{` and `}`). Here's an example (see more examples in the file `tests/strfmt-tests.typ`): ```typ -#import "@preview/oxifmt:0.2.1": strfmt +#import "@preview/oxifmt:0.3.0": strfmt #let s = strfmt("I'm {}. I have {num} cars. I'm {0}. {} is {{cool}}.", "John", "Carl", num: 10) #assert.eq(s, "I'm John. I have 10 cars. I'm John. Carl is {cool}.") @@ -67,6 +95,8 @@ You can use `{:spec}` to customize your output. See the Rust docs linked above f - Add `e` or `E` at the end of the `spec` to ensure the number is represented in scientific notation (with `e` or `E` as the exponent separator, respectively). - For decimal numbers (floats), you can specify `fmt-decimal-separator: ","` to `strfmt` to have the decimal separator be a comma instead of a dot, for example. - To have this be the default, you can alias `strfmt`, such as using `#let strfmt = strfmt.with(fmt-decimal-separator: ",")`. + - You can enable thousands separators for numbers with `fmt-thousands-separator: "_"` to separate with an underscore, for example. + - By default, thousands separators are inserted after every third digit from the end of the number. Use `fmt-thousands-count: 2` to change that to every second digit as an example. - Number spec arguments (such as `.5`) are ignored when the argument is not a number, but e.g. a string, even if it looks like a number (such as `"5"`). - Note that all spec arguments above **have to be specified in order** - if you mix up the order, it won't work properly! - Check the grammar below for the proper order, but, in summary: fill (character) with align (`<`, `>` or `^`) -> sign (`+` or `-`) -> `#` -> `0` (for 0 left-padding of numbers) -> width (e.g. `8` from `08` or `9` from `-<9`) -> `.precision` -> spec type (`?`, `x`, `X`, `b`, `o`, `e`, `E`)). @@ -74,7 +104,7 @@ You can use `{:spec}` to customize your output. See the Rust docs linked above f Some examples: ```typ -#import "@preview/oxifmt:0.2.1": strfmt +#import "@preview/oxifmt:0.3.0": strfmt #let s1 = strfmt("{0:?}, {test:+012e}, {1:-<#8x}", "hi", -74, test: 569.4) #assert.eq(s1, "\"hi\", +00005.694e2, -0x4a---") @@ -90,7 +120,7 @@ Some examples: - **Inserting labels, text and numbers into strings:** ```typ -#import "@preview/oxifmt:0.2.1": strfmt +#import "@preview/oxifmt:0.3.0": strfmt #let s = strfmt("First: {}, Second: {}, Fourth: {3}, Banana: {banana} (brackets: {{escaped}})", 1, 2.1, 3, label("four"), banana: "Banana!!") #assert.eq(s, "First: 1, Second: 2.1, Fourth: four, Banana: Banana!! (brackets: {escaped})") @@ -98,7 +128,7 @@ Some examples: - **Forcing `repr()` with `{:?}`** (which adds quotes around strings, and other things - basically represents a Typst value): ```typ -#import "@preview/oxifmt:0.2.1": strfmt +#import "@preview/oxifmt:0.3.0": strfmt #let s = strfmt("The value is: {:?} | Also the label is {:?}", "something", label("label")) #assert.eq(s, "The value is: \"something\" | Also the label is