From ebfdb1dafa430786db10dad2ef7d5467c1bdbed1 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 2 Jul 2023 19:59:52 +0200 Subject: Move everything into `crates/` directory --- .gitignore | 1 - ARCHITECTURE.md | 171 -- CONTRIBUTING.md | 4 +- Cargo.lock | 32 +- Cargo.toml | 61 +- NOTICE | 13 +- README.md | 2 +- cli/Cargo.toml | 59 - cli/build.rs | 60 - cli/src/args.rs | 119 -- cli/src/compile.rs | 239 --- cli/src/fonts.rs | 183 -- cli/src/main.rs | 79 - cli/src/package.rs | 77 - cli/src/tracing.rs | 138 -- cli/src/watch.rs | 170 -- cli/src/world.rs | 256 --- crates/typst-cli/Cargo.toml | 59 + crates/typst-cli/build.rs | 60 + crates/typst-cli/src/args.rs | 119 ++ crates/typst-cli/src/compile.rs | 239 +++ crates/typst-cli/src/fonts.rs | 183 ++ crates/typst-cli/src/main.rs | 79 + crates/typst-cli/src/package.rs | 77 + crates/typst-cli/src/tracing.rs | 138 ++ crates/typst-cli/src/watch.rs | 170 ++ crates/typst-cli/src/world.rs | 256 +++ crates/typst-docs/Cargo.toml | 27 + crates/typst-docs/src/contribs.rs | 86 + crates/typst-docs/src/html.rs | 513 +++++ crates/typst-docs/src/lib.rs | 1002 ++++++++++ crates/typst-library/Cargo.toml | 51 + .../assets/cj_linebreak_data.postcard | Bin 0 -> 18848 bytes crates/typst-library/assets/icudata.postcard | Bin 0 -> 352005 bytes crates/typst-library/assets/syntect.bin | Bin 0 -> 687378 bytes crates/typst-library/src/compute/calc.rs | 1024 ++++++++++ crates/typst-library/src/compute/construct.rs | 743 ++++++++ crates/typst-library/src/compute/data.rs | 492 +++++ crates/typst-library/src/compute/foundations.rs | 215 +++ crates/typst-library/src/compute/mod.rs | 39 + crates/typst-library/src/layout/align.rs | 67 + crates/typst-library/src/layout/columns.rs | 172 ++ crates/typst-library/src/layout/container.rs | 497 +++++ crates/typst-library/src/layout/enum.rs | 318 ++++ crates/typst-library/src/layout/flow.rs | 583 ++++++ crates/typst-library/src/layout/fragment.rs | 87 + crates/typst-library/src/layout/grid.rs | 708 +++++++ crates/typst-library/src/layout/hide.rs | 30 + crates/typst-library/src/layout/list.rs | 239 +++ crates/typst-library/src/layout/measure.rs | 56 + crates/typst-library/src/layout/mod.rs | 709 +++++++ crates/typst-library/src/layout/pad.rs | 125 ++ crates/typst-library/src/layout/page.rs | 898 +++++++++ crates/typst-library/src/layout/par.rs | 1566 +++++++++++++++ crates/typst-library/src/layout/place.rs | 103 + crates/typst-library/src/layout/regions.rs | 144 ++ crates/typst-library/src/layout/repeat.rs | 79 + crates/typst-library/src/layout/spacing.rs | 240 +++ crates/typst-library/src/layout/stack.rs | 339 ++++ crates/typst-library/src/layout/table.rs | 334 ++++ crates/typst-library/src/layout/terms.rs | 166 ++ crates/typst-library/src/layout/transform.rs | 194 ++ crates/typst-library/src/lib.rs | 141 ++ crates/typst-library/src/math/accent.rs | 139 ++ crates/typst-library/src/math/align.rs | 63 + crates/typst-library/src/math/attach.rs | 411 ++++ crates/typst-library/src/math/cancel.rs | 187 ++ crates/typst-library/src/math/ctx.rs | 268 +++ crates/typst-library/src/math/delimited.rs | 200 ++ crates/typst-library/src/math/frac.rs | 148 ++ crates/typst-library/src/math/fragment.rs | 414 ++++ crates/typst-library/src/math/matrix.rs | 313 +++ crates/typst-library/src/math/mod.rs | 480 +++++ crates/typst-library/src/math/op.rs | 113 ++ crates/typst-library/src/math/root.rs | 156 ++ crates/typst-library/src/math/row.rs | 258 +++ crates/typst-library/src/math/spacing.rs | 60 + crates/typst-library/src/math/stretch.rs | 199 ++ crates/typst-library/src/math/style.rs | 620 ++++++ crates/typst-library/src/math/underover.rs | 339 ++++ crates/typst-library/src/meta/bibliography.rs | 724 +++++++ crates/typst-library/src/meta/context.rs | 220 +++ crates/typst-library/src/meta/counter.rs | 683 +++++++ crates/typst-library/src/meta/document.rs | 86 + crates/typst-library/src/meta/figure.rs | 351 ++++ crates/typst-library/src/meta/footnote.rs | 299 +++ crates/typst-library/src/meta/heading.rs | 239 +++ crates/typst-library/src/meta/link.rs | 137 ++ crates/typst-library/src/meta/mod.rs | 64 + crates/typst-library/src/meta/numbering.rs | 525 ++++++ crates/typst-library/src/meta/outline.rs | 528 ++++++ crates/typst-library/src/meta/query.rs | 145 ++ crates/typst-library/src/meta/reference.rs | 276 +++ crates/typst-library/src/meta/state.rs | 440 +++++ crates/typst-library/src/prelude.rs | 42 + crates/typst-library/src/shared/behave.rs | 109 ++ crates/typst-library/src/shared/ext.rs | 92 + crates/typst-library/src/shared/mod.rs | 7 + crates/typst-library/src/symbols/emoji.rs | 1356 +++++++++++++ crates/typst-library/src/symbols/mod.rs | 15 + crates/typst-library/src/symbols/sym.rs | 842 +++++++++ crates/typst-library/src/text/deco.rs | 420 +++++ crates/typst-library/src/text/misc.rs | 330 ++++ crates/typst-library/src/text/mod.rs | 769 ++++++++ crates/typst-library/src/text/quotes.rs | 209 ++ crates/typst-library/src/text/raw.rs | 398 ++++ crates/typst-library/src/text/shaping.rs | 973 ++++++++++ crates/typst-library/src/text/shift.rs | 229 +++ crates/typst-library/src/visualize/image.rs | 197 ++ crates/typst-library/src/visualize/line.rs | 118 ++ crates/typst-library/src/visualize/mod.rs | 45 + crates/typst-library/src/visualize/path.rs | 211 +++ crates/typst-library/src/visualize/polygon.rs | 93 + crates/typst-library/src/visualize/shape.rs | 569 ++++++ crates/typst-macros/Cargo.toml | 22 + crates/typst-macros/src/castable.rs | 331 ++++ crates/typst-macros/src/element.rs | 554 ++++++ crates/typst-macros/src/func.rs | 268 +++ crates/typst-macros/src/lib.rs | 63 + crates/typst-macros/src/symbols.rs | 78 + crates/typst-macros/src/util.rs | 145 ++ crates/typst/Cargo.toml | 56 + crates/typst/src/diag.rs | 376 ++++ crates/typst/src/doc.rs | 719 +++++++ crates/typst/src/eval/args.rs | 216 +++ crates/typst/src/eval/array.rs | 508 +++++ crates/typst/src/eval/auto.rs | 39 + crates/typst/src/eval/cast.rs | 316 ++++ crates/typst/src/eval/datetime.rs | 201 ++ crates/typst/src/eval/dict.rs | 235 +++ crates/typst/src/eval/func.rs | 643 +++++++ crates/typst/src/eval/int.rs | 81 + crates/typst/src/eval/library.rs | 182 ++ crates/typst/src/eval/methods.rs | 373 ++++ crates/typst/src/eval/mod.rs | 1908 +++++++++++++++++++ crates/typst/src/eval/module.rs | 98 + crates/typst/src/eval/none.rs | 74 + crates/typst/src/eval/ops.rs | 429 +++++ crates/typst/src/eval/scope.rs | 178 ++ crates/typst/src/eval/str.rs | 620 ++++++ crates/typst/src/eval/symbol.rs | 210 +++ crates/typst/src/eval/value.rs | 461 +++++ crates/typst/src/export/mod.rs | 7 + crates/typst/src/export/pdf/font.rs | 204 ++ crates/typst/src/export/pdf/image.rs | 143 ++ crates/typst/src/export/pdf/mod.rs | 235 +++ crates/typst/src/export/pdf/outline.rs | 127 ++ crates/typst/src/export/pdf/page.rs | 565 ++++++ crates/typst/src/export/render.rs | 673 +++++++ crates/typst/src/file.rs | 303 +++ crates/typst/src/font/book.rs | 546 ++++++ crates/typst/src/font/mod.rs | 249 +++ crates/typst/src/font/variant.rs | 270 +++ crates/typst/src/geom/abs.rs | 266 +++ crates/typst/src/geom/align.rs | 239 +++ crates/typst/src/geom/angle.rs | 188 ++ crates/typst/src/geom/axes.rs | 305 +++ crates/typst/src/geom/color.rs | 386 ++++ crates/typst/src/geom/corners.rs | 219 +++ crates/typst/src/geom/dir.rs | 79 + crates/typst/src/geom/ellipse.rs | 22 + crates/typst/src/geom/em.rs | 153 ++ crates/typst/src/geom/fr.rs | 119 ++ crates/typst/src/geom/length.rs | 128 ++ crates/typst/src/geom/macros.rs | 47 + crates/typst/src/geom/mod.rs | 121 ++ crates/typst/src/geom/paint.rs | 30 + crates/typst/src/geom/path.rs | 54 + crates/typst/src/geom/point.rs | 146 ++ crates/typst/src/geom/ratio.rs | 133 ++ crates/typst/src/geom/rel.rs | 246 +++ crates/typst/src/geom/rounded.rs | 182 ++ crates/typst/src/geom/scalar.rs | 175 ++ crates/typst/src/geom/shape.rs | 35 + crates/typst/src/geom/sides.rs | 268 +++ crates/typst/src/geom/size.rs | 78 + crates/typst/src/geom/smart.rs | 146 ++ crates/typst/src/geom/stroke.rs | 387 ++++ crates/typst/src/geom/transform.rs | 77 + crates/typst/src/ide/analyze.rs | 111 ++ crates/typst/src/ide/complete.rs | 1201 ++++++++++++ crates/typst/src/ide/highlight.rs | 430 +++++ crates/typst/src/ide/jump.rs | 173 ++ crates/typst/src/ide/mod.rs | 97 + crates/typst/src/ide/tooltip.rs | 222 +++ crates/typst/src/image.rs | 449 +++++ crates/typst/src/lib.rs | 147 ++ crates/typst/src/model/content.rs | 614 ++++++ crates/typst/src/model/element.rs | 134 ++ crates/typst/src/model/introspect.rs | 352 ++++ crates/typst/src/model/label.rs | 16 + crates/typst/src/model/mod.rs | 148 ++ crates/typst/src/model/realize.rs | 228 +++ crates/typst/src/model/selector.rs | 296 +++ crates/typst/src/model/styles.rs | 750 ++++++++ crates/typst/src/syntax/ast.rs | 1994 ++++++++++++++++++++ crates/typst/src/syntax/kind.rs | 448 +++++ crates/typst/src/syntax/lexer.rs | 738 ++++++++ crates/typst/src/syntax/mod.rs | 23 + crates/typst/src/syntax/node.rs | 889 +++++++++ crates/typst/src/syntax/parser.rs | 1643 ++++++++++++++++ crates/typst/src/syntax/reparser.rs | 322 ++++ crates/typst/src/syntax/source.rs | 421 +++++ crates/typst/src/syntax/span.rs | 148 ++ crates/typst/src/util/bytes.rs | 59 + crates/typst/src/util/fat.rs | 55 + crates/typst/src/util/mod.rs | 268 +++ docs/Cargo.toml | 27 - docs/changelog.md | 726 +++++++ docs/community.md | 72 + docs/dev/architecture.md | 171 ++ docs/guides/guide-for-latex-users.md | 602 ++++++ docs/guides/welcome.md | 11 + docs/overview.md | 15 + docs/reference/details.yml | 174 ++ docs/reference/groups.yml | 76 + docs/reference/scripting.md | 345 ++++ docs/reference/styling.md | 145 ++ docs/reference/syntax.md | 138 ++ docs/reference/types.md | 1190 ++++++++++++ docs/reference/welcome.md | 29 + docs/src/contribs.rs | 86 - docs/src/general/changelog.md | 726 ------- docs/src/general/community.md | 72 - docs/src/general/overview.md | 15 - docs/src/guides/guide-for-latex-users.md | 602 ------ docs/src/guides/welcome.md | 11 - docs/src/html.rs | 513 ----- docs/src/lib.rs | 1002 ---------- docs/src/reference/details.yml | 174 -- docs/src/reference/groups.yml | 76 - docs/src/reference/scripting.md | 345 ---- docs/src/reference/styling.md | 145 -- docs/src/reference/syntax.md | 138 -- docs/src/reference/types.md | 1190 ------------ docs/src/reference/welcome.md | 29 - docs/src/tutorial/1-writing.md | 308 --- docs/src/tutorial/2-formatting.md | 283 --- docs/src/tutorial/3-advanced.md | 509 ----- docs/src/tutorial/4-template.md | 380 ---- docs/src/tutorial/welcome.md | 44 - docs/tutorial/1-writing.md | 308 +++ docs/tutorial/2-formatting.md | 283 +++ docs/tutorial/3-advanced.md | 509 +++++ docs/tutorial/4-template.md | 380 ++++ docs/tutorial/welcome.md | 44 + library/Cargo.toml | 51 - library/assets/cj_linebreak_data.postcard | Bin 18848 -> 0 bytes library/assets/icudata.postcard | Bin 352005 -> 0 bytes library/assets/syntect.bin | Bin 687378 -> 0 bytes library/src/compute/calc.rs | 1024 ---------- library/src/compute/construct.rs | 743 -------- library/src/compute/data.rs | 492 ----- library/src/compute/foundations.rs | 215 --- library/src/compute/mod.rs | 39 - library/src/layout/align.rs | 67 - library/src/layout/columns.rs | 172 -- library/src/layout/container.rs | 497 ----- library/src/layout/enum.rs | 318 ---- library/src/layout/flow.rs | 583 ------ library/src/layout/fragment.rs | 87 - library/src/layout/grid.rs | 708 ------- library/src/layout/hide.rs | 30 - library/src/layout/list.rs | 239 --- library/src/layout/measure.rs | 56 - library/src/layout/mod.rs | 709 ------- library/src/layout/pad.rs | 125 -- library/src/layout/page.rs | 898 --------- library/src/layout/par.rs | 1566 --------------- library/src/layout/place.rs | 103 - library/src/layout/regions.rs | 144 -- library/src/layout/repeat.rs | 79 - library/src/layout/spacing.rs | 240 --- library/src/layout/stack.rs | 339 ---- library/src/layout/table.rs | 334 ---- library/src/layout/terms.rs | 166 -- library/src/layout/transform.rs | 194 -- library/src/lib.rs | 141 -- library/src/math/accent.rs | 139 -- library/src/math/align.rs | 63 - library/src/math/attach.rs | 411 ---- library/src/math/cancel.rs | 187 -- library/src/math/ctx.rs | 268 --- library/src/math/delimited.rs | 200 -- library/src/math/frac.rs | 148 -- library/src/math/fragment.rs | 414 ---- library/src/math/matrix.rs | 313 --- library/src/math/mod.rs | 480 ----- library/src/math/op.rs | 113 -- library/src/math/root.rs | 156 -- library/src/math/row.rs | 258 --- library/src/math/spacing.rs | 60 - library/src/math/stretch.rs | 199 -- library/src/math/style.rs | 620 ------ library/src/math/underover.rs | 339 ---- library/src/meta/bibliography.rs | 724 ------- library/src/meta/context.rs | 220 --- library/src/meta/counter.rs | 683 ------- library/src/meta/document.rs | 86 - library/src/meta/figure.rs | 351 ---- library/src/meta/footnote.rs | 299 --- library/src/meta/heading.rs | 239 --- library/src/meta/link.rs | 137 -- library/src/meta/mod.rs | 64 - library/src/meta/numbering.rs | 525 ------ library/src/meta/outline.rs | 528 ------ library/src/meta/query.rs | 145 -- library/src/meta/reference.rs | 276 --- library/src/meta/state.rs | 440 ----- library/src/prelude.rs | 42 - library/src/shared/behave.rs | 109 -- library/src/shared/ext.rs | 92 - library/src/shared/mod.rs | 7 - library/src/symbols/emoji.rs | 1356 ------------- library/src/symbols/mod.rs | 15 - library/src/symbols/sym.rs | 842 --------- library/src/text/deco.rs | 420 ----- library/src/text/misc.rs | 330 ---- library/src/text/mod.rs | 769 -------- library/src/text/quotes.rs | 209 -- library/src/text/raw.rs | 398 ---- library/src/text/shaping.rs | 973 ---------- library/src/text/shift.rs | 229 --- library/src/visualize/image.rs | 197 -- library/src/visualize/line.rs | 118 -- library/src/visualize/mod.rs | 45 - library/src/visualize/path.rs | 211 --- library/src/visualize/polygon.rs | 93 - library/src/visualize/shape.rs | 569 ------ macros/Cargo.toml | 22 - macros/src/castable.rs | 331 ---- macros/src/element.rs | 554 ------ macros/src/func.rs | 268 --- macros/src/lib.rs | 63 - macros/src/symbols.rs | 78 - macros/src/util.rs | 145 -- src/diag.rs | 376 ---- src/doc.rs | 719 ------- src/eval/args.rs | 216 --- src/eval/array.rs | 508 ----- src/eval/auto.rs | 39 - src/eval/cast.rs | 316 ---- src/eval/datetime.rs | 201 -- src/eval/dict.rs | 235 --- src/eval/func.rs | 643 ------- src/eval/int.rs | 81 - src/eval/library.rs | 182 -- src/eval/methods.rs | 373 ---- src/eval/mod.rs | 1908 ------------------- src/eval/module.rs | 98 - src/eval/none.rs | 74 - src/eval/ops.rs | 429 ----- src/eval/scope.rs | 178 -- src/eval/str.rs | 620 ------ src/eval/symbol.rs | 210 --- src/eval/value.rs | 461 ----- src/export/mod.rs | 7 - src/export/pdf/font.rs | 204 -- src/export/pdf/image.rs | 143 -- src/export/pdf/mod.rs | 235 --- src/export/pdf/outline.rs | 127 -- src/export/pdf/page.rs | 565 ------ src/export/render.rs | 673 ------- src/file.rs | 303 --- src/font/book.rs | 546 ------ src/font/mod.rs | 249 --- src/font/variant.rs | 270 --- src/geom/abs.rs | 266 --- src/geom/align.rs | 239 --- src/geom/angle.rs | 188 -- src/geom/axes.rs | 305 --- src/geom/color.rs | 386 ---- src/geom/corners.rs | 219 --- src/geom/dir.rs | 79 - src/geom/ellipse.rs | 22 - src/geom/em.rs | 153 -- src/geom/fr.rs | 119 -- src/geom/length.rs | 128 -- src/geom/macros.rs | 47 - src/geom/mod.rs | 121 -- src/geom/paint.rs | 30 - src/geom/path.rs | 54 - src/geom/point.rs | 146 -- src/geom/ratio.rs | 133 -- src/geom/rel.rs | 246 --- src/geom/rounded.rs | 182 -- src/geom/scalar.rs | 175 -- src/geom/shape.rs | 35 - src/geom/sides.rs | 268 --- src/geom/size.rs | 78 - src/geom/smart.rs | 146 -- src/geom/stroke.rs | 387 ---- src/geom/transform.rs | 77 - src/ide/analyze.rs | 111 -- src/ide/complete.rs | 1201 ------------ src/ide/highlight.rs | 430 ----- src/ide/jump.rs | 173 -- src/ide/mod.rs | 97 - src/ide/tooltip.rs | 222 --- src/image.rs | 449 ----- src/lib.rs | 147 -- src/model/content.rs | 614 ------ src/model/element.rs | 134 -- src/model/introspect.rs | 352 ---- src/model/label.rs | 16 - src/model/mod.rs | 148 -- src/model/realize.rs | 228 --- src/model/selector.rs | 296 --- src/model/styles.rs | 750 -------- src/syntax/ast.rs | 1994 -------------------- src/syntax/kind.rs | 448 ----- src/syntax/lexer.rs | 738 -------- src/syntax/mod.rs | 23 - src/syntax/node.rs | 889 --------- src/syntax/parser.rs | 1643 ---------------- src/syntax/reparser.rs | 322 ---- src/syntax/source.rs | 421 ----- src/syntax/span.rs | 148 -- src/util/bytes.rs | 59 - src/util/fat.rs | 55 - src/util/mod.rs | 268 --- tests/Cargo.toml | 4 +- 422 files changed, 64030 insertions(+), 64041 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 cli/Cargo.toml delete mode 100644 cli/build.rs delete mode 100644 cli/src/args.rs delete mode 100644 cli/src/compile.rs delete mode 100644 cli/src/fonts.rs delete mode 100644 cli/src/main.rs delete mode 100644 cli/src/package.rs delete mode 100644 cli/src/tracing.rs delete mode 100644 cli/src/watch.rs delete mode 100644 cli/src/world.rs create mode 100644 crates/typst-cli/Cargo.toml create mode 100644 crates/typst-cli/build.rs create mode 100644 crates/typst-cli/src/args.rs create mode 100644 crates/typst-cli/src/compile.rs create mode 100644 crates/typst-cli/src/fonts.rs create mode 100644 crates/typst-cli/src/main.rs create mode 100644 crates/typst-cli/src/package.rs create mode 100644 crates/typst-cli/src/tracing.rs create mode 100644 crates/typst-cli/src/watch.rs create mode 100644 crates/typst-cli/src/world.rs create mode 100644 crates/typst-docs/Cargo.toml create mode 100644 crates/typst-docs/src/contribs.rs create mode 100644 crates/typst-docs/src/html.rs create mode 100644 crates/typst-docs/src/lib.rs create mode 100644 crates/typst-library/Cargo.toml create mode 100644 crates/typst-library/assets/cj_linebreak_data.postcard create mode 100644 crates/typst-library/assets/icudata.postcard create mode 100644 crates/typst-library/assets/syntect.bin create mode 100644 crates/typst-library/src/compute/calc.rs create mode 100644 crates/typst-library/src/compute/construct.rs create mode 100644 crates/typst-library/src/compute/data.rs create mode 100644 crates/typst-library/src/compute/foundations.rs create mode 100644 crates/typst-library/src/compute/mod.rs create mode 100644 crates/typst-library/src/layout/align.rs create mode 100644 crates/typst-library/src/layout/columns.rs create mode 100644 crates/typst-library/src/layout/container.rs create mode 100644 crates/typst-library/src/layout/enum.rs create mode 100644 crates/typst-library/src/layout/flow.rs create mode 100644 crates/typst-library/src/layout/fragment.rs create mode 100644 crates/typst-library/src/layout/grid.rs create mode 100644 crates/typst-library/src/layout/hide.rs create mode 100644 crates/typst-library/src/layout/list.rs create mode 100644 crates/typst-library/src/layout/measure.rs create mode 100644 crates/typst-library/src/layout/mod.rs create mode 100644 crates/typst-library/src/layout/pad.rs create mode 100644 crates/typst-library/src/layout/page.rs create mode 100644 crates/typst-library/src/layout/par.rs create mode 100644 crates/typst-library/src/layout/place.rs create mode 100644 crates/typst-library/src/layout/regions.rs create mode 100644 crates/typst-library/src/layout/repeat.rs create mode 100644 crates/typst-library/src/layout/spacing.rs create mode 100644 crates/typst-library/src/layout/stack.rs create mode 100644 crates/typst-library/src/layout/table.rs create mode 100644 crates/typst-library/src/layout/terms.rs create mode 100644 crates/typst-library/src/layout/transform.rs create mode 100644 crates/typst-library/src/lib.rs create mode 100644 crates/typst-library/src/math/accent.rs create mode 100644 crates/typst-library/src/math/align.rs create mode 100644 crates/typst-library/src/math/attach.rs create mode 100644 crates/typst-library/src/math/cancel.rs create mode 100644 crates/typst-library/src/math/ctx.rs create mode 100644 crates/typst-library/src/math/delimited.rs create mode 100644 crates/typst-library/src/math/frac.rs create mode 100644 crates/typst-library/src/math/fragment.rs create mode 100644 crates/typst-library/src/math/matrix.rs create mode 100644 crates/typst-library/src/math/mod.rs create mode 100644 crates/typst-library/src/math/op.rs create mode 100644 crates/typst-library/src/math/root.rs create mode 100644 crates/typst-library/src/math/row.rs create mode 100644 crates/typst-library/src/math/spacing.rs create mode 100644 crates/typst-library/src/math/stretch.rs create mode 100644 crates/typst-library/src/math/style.rs create mode 100644 crates/typst-library/src/math/underover.rs create mode 100644 crates/typst-library/src/meta/bibliography.rs create mode 100644 crates/typst-library/src/meta/context.rs create mode 100644 crates/typst-library/src/meta/counter.rs create mode 100644 crates/typst-library/src/meta/document.rs create mode 100644 crates/typst-library/src/meta/figure.rs create mode 100644 crates/typst-library/src/meta/footnote.rs create mode 100644 crates/typst-library/src/meta/heading.rs create mode 100644 crates/typst-library/src/meta/link.rs create mode 100644 crates/typst-library/src/meta/mod.rs create mode 100644 crates/typst-library/src/meta/numbering.rs create mode 100644 crates/typst-library/src/meta/outline.rs create mode 100644 crates/typst-library/src/meta/query.rs create mode 100644 crates/typst-library/src/meta/reference.rs create mode 100644 crates/typst-library/src/meta/state.rs create mode 100644 crates/typst-library/src/prelude.rs create mode 100644 crates/typst-library/src/shared/behave.rs create mode 100644 crates/typst-library/src/shared/ext.rs create mode 100644 crates/typst-library/src/shared/mod.rs create mode 100644 crates/typst-library/src/symbols/emoji.rs create mode 100644 crates/typst-library/src/symbols/mod.rs create mode 100644 crates/typst-library/src/symbols/sym.rs create mode 100644 crates/typst-library/src/text/deco.rs create mode 100644 crates/typst-library/src/text/misc.rs create mode 100644 crates/typst-library/src/text/mod.rs create mode 100644 crates/typst-library/src/text/quotes.rs create mode 100644 crates/typst-library/src/text/raw.rs create mode 100644 crates/typst-library/src/text/shaping.rs create mode 100644 crates/typst-library/src/text/shift.rs create mode 100644 crates/typst-library/src/visualize/image.rs create mode 100644 crates/typst-library/src/visualize/line.rs create mode 100644 crates/typst-library/src/visualize/mod.rs create mode 100644 crates/typst-library/src/visualize/path.rs create mode 100644 crates/typst-library/src/visualize/polygon.rs create mode 100644 crates/typst-library/src/visualize/shape.rs create mode 100644 crates/typst-macros/Cargo.toml create mode 100644 crates/typst-macros/src/castable.rs create mode 100644 crates/typst-macros/src/element.rs create mode 100644 crates/typst-macros/src/func.rs create mode 100644 crates/typst-macros/src/lib.rs create mode 100644 crates/typst-macros/src/symbols.rs create mode 100644 crates/typst-macros/src/util.rs create mode 100644 crates/typst/Cargo.toml create mode 100644 crates/typst/src/diag.rs create mode 100644 crates/typst/src/doc.rs create mode 100644 crates/typst/src/eval/args.rs create mode 100644 crates/typst/src/eval/array.rs create mode 100644 crates/typst/src/eval/auto.rs create mode 100644 crates/typst/src/eval/cast.rs create mode 100644 crates/typst/src/eval/datetime.rs create mode 100644 crates/typst/src/eval/dict.rs create mode 100644 crates/typst/src/eval/func.rs create mode 100644 crates/typst/src/eval/int.rs create mode 100644 crates/typst/src/eval/library.rs create mode 100644 crates/typst/src/eval/methods.rs create mode 100644 crates/typst/src/eval/mod.rs create mode 100644 crates/typst/src/eval/module.rs create mode 100644 crates/typst/src/eval/none.rs create mode 100644 crates/typst/src/eval/ops.rs create mode 100644 crates/typst/src/eval/scope.rs create mode 100644 crates/typst/src/eval/str.rs create mode 100644 crates/typst/src/eval/symbol.rs create mode 100644 crates/typst/src/eval/value.rs create mode 100644 crates/typst/src/export/mod.rs create mode 100644 crates/typst/src/export/pdf/font.rs create mode 100644 crates/typst/src/export/pdf/image.rs create mode 100644 crates/typst/src/export/pdf/mod.rs create mode 100644 crates/typst/src/export/pdf/outline.rs create mode 100644 crates/typst/src/export/pdf/page.rs create mode 100644 crates/typst/src/export/render.rs create mode 100644 crates/typst/src/file.rs create mode 100644 crates/typst/src/font/book.rs create mode 100644 crates/typst/src/font/mod.rs create mode 100644 crates/typst/src/font/variant.rs create mode 100644 crates/typst/src/geom/abs.rs create mode 100644 crates/typst/src/geom/align.rs create mode 100644 crates/typst/src/geom/angle.rs create mode 100644 crates/typst/src/geom/axes.rs create mode 100644 crates/typst/src/geom/color.rs create mode 100644 crates/typst/src/geom/corners.rs create mode 100644 crates/typst/src/geom/dir.rs create mode 100644 crates/typst/src/geom/ellipse.rs create mode 100644 crates/typst/src/geom/em.rs create mode 100644 crates/typst/src/geom/fr.rs create mode 100644 crates/typst/src/geom/length.rs create mode 100644 crates/typst/src/geom/macros.rs create mode 100644 crates/typst/src/geom/mod.rs create mode 100644 crates/typst/src/geom/paint.rs create mode 100644 crates/typst/src/geom/path.rs create mode 100644 crates/typst/src/geom/point.rs create mode 100644 crates/typst/src/geom/ratio.rs create mode 100644 crates/typst/src/geom/rel.rs create mode 100644 crates/typst/src/geom/rounded.rs create mode 100644 crates/typst/src/geom/scalar.rs create mode 100644 crates/typst/src/geom/shape.rs create mode 100644 crates/typst/src/geom/sides.rs create mode 100644 crates/typst/src/geom/size.rs create mode 100644 crates/typst/src/geom/smart.rs create mode 100644 crates/typst/src/geom/stroke.rs create mode 100644 crates/typst/src/geom/transform.rs create mode 100644 crates/typst/src/ide/analyze.rs create mode 100644 crates/typst/src/ide/complete.rs create mode 100644 crates/typst/src/ide/highlight.rs create mode 100644 crates/typst/src/ide/jump.rs create mode 100644 crates/typst/src/ide/mod.rs create mode 100644 crates/typst/src/ide/tooltip.rs create mode 100644 crates/typst/src/image.rs create mode 100644 crates/typst/src/lib.rs create mode 100644 crates/typst/src/model/content.rs create mode 100644 crates/typst/src/model/element.rs create mode 100644 crates/typst/src/model/introspect.rs create mode 100644 crates/typst/src/model/label.rs create mode 100644 crates/typst/src/model/mod.rs create mode 100644 crates/typst/src/model/realize.rs create mode 100644 crates/typst/src/model/selector.rs create mode 100644 crates/typst/src/model/styles.rs create mode 100644 crates/typst/src/syntax/ast.rs create mode 100644 crates/typst/src/syntax/kind.rs create mode 100644 crates/typst/src/syntax/lexer.rs create mode 100644 crates/typst/src/syntax/mod.rs create mode 100644 crates/typst/src/syntax/node.rs create mode 100644 crates/typst/src/syntax/parser.rs create mode 100644 crates/typst/src/syntax/reparser.rs create mode 100644 crates/typst/src/syntax/source.rs create mode 100644 crates/typst/src/syntax/span.rs create mode 100644 crates/typst/src/util/bytes.rs create mode 100644 crates/typst/src/util/fat.rs create mode 100644 crates/typst/src/util/mod.rs delete mode 100644 docs/Cargo.toml create mode 100644 docs/changelog.md create mode 100644 docs/community.md create mode 100644 docs/dev/architecture.md create mode 100644 docs/guides/guide-for-latex-users.md create mode 100644 docs/guides/welcome.md create mode 100644 docs/overview.md create mode 100644 docs/reference/details.yml create mode 100644 docs/reference/groups.yml create mode 100644 docs/reference/scripting.md create mode 100644 docs/reference/styling.md create mode 100644 docs/reference/syntax.md create mode 100644 docs/reference/types.md create mode 100644 docs/reference/welcome.md delete mode 100644 docs/src/contribs.rs delete mode 100644 docs/src/general/changelog.md delete mode 100644 docs/src/general/community.md delete mode 100644 docs/src/general/overview.md delete mode 100644 docs/src/guides/guide-for-latex-users.md delete mode 100644 docs/src/guides/welcome.md delete mode 100644 docs/src/html.rs delete mode 100644 docs/src/lib.rs delete mode 100644 docs/src/reference/details.yml delete mode 100644 docs/src/reference/groups.yml delete mode 100644 docs/src/reference/scripting.md delete mode 100644 docs/src/reference/styling.md delete mode 100644 docs/src/reference/syntax.md delete mode 100644 docs/src/reference/types.md delete mode 100644 docs/src/reference/welcome.md delete mode 100644 docs/src/tutorial/1-writing.md delete mode 100644 docs/src/tutorial/2-formatting.md delete mode 100644 docs/src/tutorial/3-advanced.md delete mode 100644 docs/src/tutorial/4-template.md delete mode 100644 docs/src/tutorial/welcome.md create mode 100644 docs/tutorial/1-writing.md create mode 100644 docs/tutorial/2-formatting.md create mode 100644 docs/tutorial/3-advanced.md create mode 100644 docs/tutorial/4-template.md create mode 100644 docs/tutorial/welcome.md delete mode 100644 library/Cargo.toml delete mode 100644 library/assets/cj_linebreak_data.postcard delete mode 100644 library/assets/icudata.postcard delete mode 100644 library/assets/syntect.bin delete mode 100644 library/src/compute/calc.rs delete mode 100644 library/src/compute/construct.rs delete mode 100644 library/src/compute/data.rs delete mode 100644 library/src/compute/foundations.rs delete mode 100644 library/src/compute/mod.rs delete mode 100644 library/src/layout/align.rs delete mode 100644 library/src/layout/columns.rs delete mode 100644 library/src/layout/container.rs delete mode 100644 library/src/layout/enum.rs delete mode 100644 library/src/layout/flow.rs delete mode 100644 library/src/layout/fragment.rs delete mode 100644 library/src/layout/grid.rs delete mode 100644 library/src/layout/hide.rs delete mode 100644 library/src/layout/list.rs delete mode 100644 library/src/layout/measure.rs delete mode 100644 library/src/layout/mod.rs delete mode 100644 library/src/layout/pad.rs delete mode 100644 library/src/layout/page.rs delete mode 100644 library/src/layout/par.rs delete mode 100644 library/src/layout/place.rs delete mode 100644 library/src/layout/regions.rs delete mode 100644 library/src/layout/repeat.rs delete mode 100644 library/src/layout/spacing.rs delete mode 100644 library/src/layout/stack.rs delete mode 100644 library/src/layout/table.rs delete mode 100644 library/src/layout/terms.rs delete mode 100644 library/src/layout/transform.rs delete mode 100644 library/src/lib.rs delete mode 100644 library/src/math/accent.rs delete mode 100644 library/src/math/align.rs delete mode 100644 library/src/math/attach.rs delete mode 100644 library/src/math/cancel.rs delete mode 100644 library/src/math/ctx.rs delete mode 100644 library/src/math/delimited.rs delete mode 100644 library/src/math/frac.rs delete mode 100644 library/src/math/fragment.rs delete mode 100644 library/src/math/matrix.rs delete mode 100644 library/src/math/mod.rs delete mode 100644 library/src/math/op.rs delete mode 100644 library/src/math/root.rs delete mode 100644 library/src/math/row.rs delete mode 100644 library/src/math/spacing.rs delete mode 100644 library/src/math/stretch.rs delete mode 100644 library/src/math/style.rs delete mode 100644 library/src/math/underover.rs delete mode 100644 library/src/meta/bibliography.rs delete mode 100644 library/src/meta/context.rs delete mode 100644 library/src/meta/counter.rs delete mode 100644 library/src/meta/document.rs delete mode 100644 library/src/meta/figure.rs delete mode 100644 library/src/meta/footnote.rs delete mode 100644 library/src/meta/heading.rs delete mode 100644 library/src/meta/link.rs delete mode 100644 library/src/meta/mod.rs delete mode 100644 library/src/meta/numbering.rs delete mode 100644 library/src/meta/outline.rs delete mode 100644 library/src/meta/query.rs delete mode 100644 library/src/meta/reference.rs delete mode 100644 library/src/meta/state.rs delete mode 100644 library/src/prelude.rs delete mode 100644 library/src/shared/behave.rs delete mode 100644 library/src/shared/ext.rs delete mode 100644 library/src/shared/mod.rs delete mode 100644 library/src/symbols/emoji.rs delete mode 100644 library/src/symbols/mod.rs delete mode 100644 library/src/symbols/sym.rs delete mode 100644 library/src/text/deco.rs delete mode 100644 library/src/text/misc.rs delete mode 100644 library/src/text/mod.rs delete mode 100644 library/src/text/quotes.rs delete mode 100644 library/src/text/raw.rs delete mode 100644 library/src/text/shaping.rs delete mode 100644 library/src/text/shift.rs delete mode 100644 library/src/visualize/image.rs delete mode 100644 library/src/visualize/line.rs delete mode 100644 library/src/visualize/mod.rs delete mode 100644 library/src/visualize/path.rs delete mode 100644 library/src/visualize/polygon.rs delete mode 100644 library/src/visualize/shape.rs delete mode 100644 macros/Cargo.toml delete mode 100644 macros/src/castable.rs delete mode 100644 macros/src/element.rs delete mode 100644 macros/src/func.rs delete mode 100644 macros/src/lib.rs delete mode 100644 macros/src/symbols.rs delete mode 100644 macros/src/util.rs delete mode 100644 src/diag.rs delete mode 100644 src/doc.rs delete mode 100644 src/eval/args.rs delete mode 100644 src/eval/array.rs delete mode 100644 src/eval/auto.rs delete mode 100644 src/eval/cast.rs delete mode 100644 src/eval/datetime.rs delete mode 100644 src/eval/dict.rs delete mode 100644 src/eval/func.rs delete mode 100644 src/eval/int.rs delete mode 100644 src/eval/library.rs delete mode 100644 src/eval/methods.rs delete mode 100644 src/eval/mod.rs delete mode 100644 src/eval/module.rs delete mode 100644 src/eval/none.rs delete mode 100644 src/eval/ops.rs delete mode 100644 src/eval/scope.rs delete mode 100644 src/eval/str.rs delete mode 100644 src/eval/symbol.rs delete mode 100644 src/eval/value.rs delete mode 100644 src/export/mod.rs delete mode 100644 src/export/pdf/font.rs delete mode 100644 src/export/pdf/image.rs delete mode 100644 src/export/pdf/mod.rs delete mode 100644 src/export/pdf/outline.rs delete mode 100644 src/export/pdf/page.rs delete mode 100644 src/export/render.rs delete mode 100644 src/file.rs delete mode 100644 src/font/book.rs delete mode 100644 src/font/mod.rs delete mode 100644 src/font/variant.rs delete mode 100644 src/geom/abs.rs delete mode 100644 src/geom/align.rs delete mode 100644 src/geom/angle.rs delete mode 100644 src/geom/axes.rs delete mode 100644 src/geom/color.rs delete mode 100644 src/geom/corners.rs delete mode 100644 src/geom/dir.rs delete mode 100644 src/geom/ellipse.rs delete mode 100644 src/geom/em.rs delete mode 100644 src/geom/fr.rs delete mode 100644 src/geom/length.rs delete mode 100644 src/geom/macros.rs delete mode 100644 src/geom/mod.rs delete mode 100644 src/geom/paint.rs delete mode 100644 src/geom/path.rs delete mode 100644 src/geom/point.rs delete mode 100644 src/geom/ratio.rs delete mode 100644 src/geom/rel.rs delete mode 100644 src/geom/rounded.rs delete mode 100644 src/geom/scalar.rs delete mode 100644 src/geom/shape.rs delete mode 100644 src/geom/sides.rs delete mode 100644 src/geom/size.rs delete mode 100644 src/geom/smart.rs delete mode 100644 src/geom/stroke.rs delete mode 100644 src/geom/transform.rs delete mode 100644 src/ide/analyze.rs delete mode 100644 src/ide/complete.rs delete mode 100644 src/ide/highlight.rs delete mode 100644 src/ide/jump.rs delete mode 100644 src/ide/mod.rs delete mode 100644 src/ide/tooltip.rs delete mode 100644 src/image.rs delete mode 100644 src/lib.rs delete mode 100644 src/model/content.rs delete mode 100644 src/model/element.rs delete mode 100644 src/model/introspect.rs delete mode 100644 src/model/label.rs delete mode 100644 src/model/mod.rs delete mode 100644 src/model/realize.rs delete mode 100644 src/model/selector.rs delete mode 100644 src/model/styles.rs delete mode 100644 src/syntax/ast.rs delete mode 100644 src/syntax/kind.rs delete mode 100644 src/syntax/lexer.rs delete mode 100644 src/syntax/mod.rs delete mode 100644 src/syntax/node.rs delete mode 100644 src/syntax/parser.rs delete mode 100644 src/syntax/reparser.rs delete mode 100644 src/syntax/source.rs delete mode 100644 src/syntax/span.rs delete mode 100644 src/util/bytes.rs delete mode 100644 src/util/fat.rs delete mode 100644 src/util/mod.rs diff --git a/.gitignore b/.gitignore index e5e917d1..608ba460 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ tarpaulin-report.html # Rust /target -bench/target **/*.rs.bk # Node diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index af48401d..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,171 +0,0 @@ -# Typst Compiler Architecture -Wondering how to contribute or just curious how Typst works? This document -covers the general architecture of Typst's compiler, so you get an understanding -of what's where and how everything fits together. - -The source-to-PDF compilation process of a Typst file proceeds in four phases. - -1. **Parsing:** Turns a source string into a syntax tree. -2. **Evaluation:** Turns a syntax tree and its dependencies into content. -4. **Layout:** Layouts content into frames. -5. **Export:** Turns frames into an output format like PDF or a raster graphic. - -The Typst compiler is _incremental:_ Recompiling a document that was compiled -previously is much faster than compiling from scratch. Most of the hard work is -done by [`comemo`], an incremental compilation framework we have written for -Typst. However, the compiler is still carefully written with incrementality in -mind. Below we discuss the four phases and how incrementality affects each of -them. - - -## Parsing -The syntax tree and parser are located in `src/syntax`. Parsing is a pure -function `&str -> SyntaxNode` without any further dependencies. The result is a -concrete syntax tree reflecting the whole file structure, including whitespace -and comments. Parsing cannot fail. If there are syntactic errors, the returned -syntax tree contains error nodes instead. It's important that the parser deals -well with broken code because it is also used for syntax highlighting and IDE -functionality. - -**Typedness:** -The syntax tree is untyped, any node can have any `SyntaxKind`. This makes it -very easy to (a) attach spans to each node (see below), (b) traverse the tree -when doing highlighting or IDE analyses (no extra complications like a visitor -pattern). The `typst::syntax::ast` module provides a typed API on top of -the raw tree. This API resembles a more classical AST and is used by the -interpreter. - -**Spans:** -After parsing, the syntax tree is numbered with _span numbers._ These numbers -are unique identifiers for syntax nodes that are used to trace back errors in -later compilation phases to a piece of syntax. The span numbers are ordered so -that the node corresponding to a number can be found quickly. - -**Incremental:** -Typst has an incremental parser that can reparse a segment of markup or a -code/content block. After incremental parsing, span numbers are reassigned -locally. This way, span numbers further away from an edit stay mostly stable. -This is important because they are used pervasively throughout the compiler, -also as input to memoized functions. The less they change, the better for -incremental compilation. - - -## Evaluation -The evaluation phase lives in `src/eval`. It takes a parsed `Source` file and -evaluates it to a `Module`. A module consists of the `Content` that was written -in it and a `Scope` with the bindings that were defined within it. - -A source file may depend on other files (imported sources, images, data files), -which need to be resolved. Since Typst is deployed in different environments -(CLI, web app, etc.) these system dependencies are resolved through a general -interface called a `World`. Apart from files, the world also provides -configuration and fonts. - -**Interpreter:** -Typst implements a tree-walking interpreter. To evaluate a piece of source, you -first create a `Vm` with a scope stack. Then, the AST is recursively evaluated -through trait impls of the form `fn eval(&self, vm: &mut Vm) -> Result`. -An interesting detail is how closures are dealt with: When the interpreter sees -a closure / function definition, it walks the body of the closure and finds all -accesses to variables that aren't defined within the closure. It then clones the -values of all these variables (it _captures_ them) and stores them alongside the -closure's syntactical definition in a closure value. When the closure is called, -a fresh `Vm` is created and its scope stack is initialized with the captured -variables. - -**Incremental:** -In this phase, incremental compilation happens at the granularity of the module -and the closure. Typst memoizes the result of evaluating a source file across -compilations. Furthermore, it memoizes the result of calling a closure with a -certain set of parameters. This is possible because Typst ensures that all -functions are pure. The result of a closure call can be recycled if the closure -has the same syntax and captures, even if the closure values stems from a -different module evaluation (i.e. if a module is reevaluated, previous calls to -closures defined in the module can still be reused). - - -## Layout -The layout phase takes `Content` and produces one `Frame` per page for it. To -layout `Content`, we first have to _realize_ it by applying all relevant show -rules to the content. Since show rules may be defined as Typst closures, -realization can trigger closure evaluation, which in turn produces content that -is recursively realized. Realization is a shallow process: While collecting list -items into a list that we want to layout, we don't realize the content within -the list items just yet. This only happens lazily once the list items are -layouted. - -When we a have realized the content into a layoutable element, we can then -layout it into _regions,_ which describe the space into which the content shall -be layouted. Within these, an element is free to layout itself as it sees fit, -returning one `Frame` per region it wants to occupy. - -**Introspection:** -How content layouts (and realizes) may depend on how _it itself_ is layouted -(e.g., through page numbers in the table of contents, counters, state, etc.). -Typst resolves these inherently cyclical dependencies through the _introspection -loop:_ The layout phase runs in a loop until the results stabilize. Most -introspections stabilize after one or two iterations. However, some may never -stabilize, so we give up after five attempts. - -**Incremental:** -Layout caching happens at the granularity of the element. This is important -because overall layout is the most expensive compilation phase, so we want to -reuse as much as possible. - - -## Export -Exporters live in `src/export`. They turn layouted frames into an output file -format. - -- The PDF exporter takes layouted frames and turns them into a PDF file. -- The built-in renderer takes a frame and turns it into a pixel buffer. -- HTML export does not exist yet, but will in the future. However, this requires - some complex compiler work because the export will start with `Content` - instead of `Frames` (layout is the browser's job). - - -## IDE -The `src/ide` module implements IDE functionality for Typst. It builds heavily -on the other modules (most importantly, `syntax` and `eval`). - -**Syntactic:** -Basic IDE functionality is based on a file's syntax. However, the standard -syntax node is a bit too limited for writing IDE tooling. It doesn't provide -access to its parents or neighbours. This is a fine for an evaluation-like -recursive traversal, but impractical for IDE use cases. For this reason, there -is an additional abstraction on top of a syntax node called a `LinkedNode`, -which is used pervasively across the `ide` module. - -**Semantic:** -More advanced functionality like autocompletion requires semantic analysis of -the source. To gain semantic information for things like hover tooltips, we -directly use other parts of the compiler. For instance, to find out the type of -a variable, we evaluate and realize the full document equipped with a `Tracer` -that emits the variable's value whenever it is visited. From the set of -resulting values, we can then compute the set of types a value takes on. Thanks -to incremental compilation, we can recycle large parts of the compilation that -we had to do anyway to typeset the document. - -**Incremental:** -Syntactic IDE stuff is relatively cheap for now, so there are no special -incrementality concerns. Semantic analysis with a tracer is relatively -expensive. However, large parts of a traced analysis compilation can reuse -memoized results from a previous normal compilation. Only the module evaluation -of the active file and layout code that somewhere within evaluates source code -in the active file needs to re-run. This is all handled automatically by -`comemo` because the tracer is wrapped in a `comemo::TrackedMut` container. - - -## Tests -Typst has an extensive suite of integration tests. A test file consists of -multiple tests that are separated by `---`. For each test file, we store a -reference image defining what the compiler _should_ output. To manage the -reference images, you can use the VS code extension in `tools/test-helper`. - -The integration tests cover parsing, evaluation, realization, layout and -rendering. PDF output is sadly untested, but most bugs are in earlier phases of -the compiler; the PDF output itself is relatively straight-forward. IDE -functionality is also mostly untested. PDF and IDE testing should be added in -the future. - -[`comemo`]: https://github.com/typst/comemo/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5848b863..dfa836d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ land your contribution below. 2. Fork the Typst repository and start with your contribution. If you, at any point in this process, are unsure about how to do something in the Typst codebase, reach out to a maintainer or a more experienced contributor. Also - have a look at the [`ARCHITECTURE.md`][architecture] file. It explains how + have a look at the [`architecture.md`][architecture] file. It explains how the compiler works. 3. Create a pull request (PR) in the Typst repository, outlining your contribution, the technical rationale behind it, and, if it includes a new @@ -69,5 +69,5 @@ Typst! [issue]: https://github.com/typst/typst/issues [testing]: https://github.com/typst/typst/blob/main/tests/README.md [#contributors]: https://discord.com/channels/1054443721975922748/1088371867913572452 -[architecture]: https://github.com/typst/typst/blob/main/ARCHITECTURE.md +[architecture]: https://github.com/typst/typst/blob/main/docs/dev/architecture.md [changelog]: https://typst.app/docs/changelog/ diff --git a/Cargo.lock b/Cargo.lock index 044c7c98..07621fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -425,9 +425,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.14" +version = "0.9.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46bd5f3f85273295a9d14aedfb86f6aadbff6d8f5295c4a9edb08e819dcf5695" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" dependencies = [ "autocfg", "cfg-if", @@ -751,15 +751,6 @@ dependencies = [ "libc", ] -[[package]] -name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.3.1" @@ -1245,9 +1236,9 @@ dependencies = [ [[package]] name = "memoffset" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" dependencies = [ "autocfg", ] @@ -1356,11 +1347,11 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi 0.3.1", "libc", ] @@ -1394,9 +1385,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "os_str_bytes" -version = "6.5.0" +version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceedf44fb00f2d1984b0bc98102627ce622e083e49a5bacdb3e514fa4238e267" +checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" [[package]] name = "overload" @@ -3218,11 +3209,10 @@ dependencies = [ [[package]] name = "zopfli" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5b2bed49d3f0af28729a2338ee8c3a48eba2133a78ebc560779be161ebaaad8" +checksum = "4e0650ae6a051326d798eb099b632f1afb0d323d25ee4ec82ffb0779512084d5" dependencies = [ - "byteorder", "crc32fast", "log", "simd-adler32", diff --git a/Cargo.toml b/Cargo.toml index 5f1e77e9..ad3a57f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = ["cli", "docs", "library", "macros", "tests"] -default-members = ["cli"] +members = ["crates/*", "tests"] +default-members = ["crates/typst-cli"] [workspace.package] version = "0.6.0" @@ -12,63 +12,6 @@ repository = "https://github.com/typst/typst" readme = "README.md" license = "Apache-2.0" -[package] -name = "typst" -description = "A new markup-based typesetting system that is powerful and easy to learn." -categories = ["compilers", "science"] -keywords = ["markup", "typesetting"] -version.workspace = true -rust-version.workspace = true -authors.workspace = true -edition.workspace = true -homepage.workspace = true -repository.workspace = true -license.workspace = true - -[lib] -doctest = false -bench = false - -[dependencies] -typst-macros = { path = "macros" } -bitflags = { version = "2", features = ["serde"] } -bytemuck = "1" -comemo = "0.3" -ecow = { version = "0.1.1", features = ["serde"] } -flate2 = "1" -fontdb = "0.13" -if_chain = "1" -image = { version = "0.24", default-features = false, features = ["png", "jpeg", "gif"] } -indexmap = "1.9.3" -log = "0.4" -miniz_oxide = "0.7" -once_cell = "1" -pdf-writer = "0.7.1" -pixglyph = "0.1" -regex = "1" -resvg = { version = "0.32", default-features = false } -roxmltree = "0.18" -rustybuzz = "0.7" -serde = { version = "1", features = ["derive"] } -siphasher = "0.3" -subsetter = "0.1.1" -svg2pdf = { git = "https://github.com/typst/svg2pdf" } -tiny-skia = "0.9.0" -toml = { version = "0.7.3", default-features = false, features = ["parse"] } -tracing = "0.1.37" -ttf-parser = "0.18.1" -unicode-general-category = "0.6" -unicode-ident = "1.0" -unicode-math-class = "0.1" -unicode-segmentation = "1" -unscanny = "0.1" -usvg = { version = "0.32", default-features = false, features = ["text"] } -xmp-writer = "0.1" -time = { version = "0.3.20", features = ["std", "formatting"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -stacker = "0.1.15" - [profile.dev.package."*"] opt-level = 2 diff --git a/NOTICE b/NOTICE index 9fef0f4e..d2ebb9da 100644 --- a/NOTICE +++ b/NOTICE @@ -3,8 +3,8 @@ Licenses for third party components used by this project can be found below. ================================================================================ The MIT License applies to: -* The default color set defined in `src/geom/color.rs` which is adapted from - the colors.css project +* The default color set defined in `crates/typst/src/geom/color.rs` which is + adapted from the colors.css project (https://clrs.cc/) The MIT License (MIT) @@ -29,8 +29,9 @@ THE SOFTWARE. ================================================================================ ================================================================================ -Alpha multiplication and source-over blending in `src/export/render.rs` are -ported from Skia code which can be found here: +Alpha multiplication and source-over blending in +`crates/typst/src/export/render.rs` are ported from Skia code which can be found +here: https://skia.googlesource.com/skia/+/refs/heads/main/include/core/SkColorPriv.h Copyright (c) 2011 Google Inc. All rights reserved. @@ -63,8 +64,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================================================ ================================================================================ -Syntaxes in `library/assets/syntect.bin` are generated from syntax definitions -which can be found here: +Syntaxes in `crates/typst-library/assets/syntect.bin` are generated from syntax +definitions which can be found here: https://github.com/sharkdp/bat/tree/master/assets/syntaxes The syntax definitions are used under their respective licenses. diff --git a/README.md b/README.md index b9ca6451..9459c61e 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ instant preview. To achieve these goals, we follow three core design principles: [scripting]: https://typst.app/docs/reference/scripting/ [rust]: https://rustup.rs/ [releases]: https://github.com/typst/typst/releases/ -[architecture]: https://github.com/typst/typst/blob/main/ARCHITECTURE.md +[architecture]: https://github.com/typst/typst/blob/main/docs/dev/architecture.md [contributing]: https://github.com/typst/typst/blob/main/CONTRIBUTING.md [packages]: https://github.com/typst/packages/ [`comemo`]: https://github.com/typst/comemo/ diff --git a/cli/Cargo.toml b/cli/Cargo.toml deleted file mode 100644 index e8058df0..00000000 --- a/cli/Cargo.toml +++ /dev/null @@ -1,59 +0,0 @@ -[package] -name = "typst-cli" -description = "The command line interface for Typst." -categories = ["compilers", "command-line-utilities"] -keywords = ["typst", "cli"] -version.workspace = true -rust-version.workspace = true -authors.workspace = true -edition.workspace = true -homepage.workspace = true -repository.workspace = true -license.workspace = true - -[[bin]] -name = "typst" -path = "src/main.rs" -test = false -doctest = false -bench = false -doc = false - -[dependencies] -typst = { path = ".." } -typst-library = { path = "../library" } -chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] } -clap = { version = "4.2.4", features = ["derive", "env"] } -codespan-reporting = "0.11" -comemo = "0.3" -dirs = "5" -flate2 = "1" -inferno = "0.11.15" -memmap2 = "0.5" -notify = "5" -once_cell = "1" -open = "4.0.2" -same-file = "1" -siphasher = "0.3" -tar = "0.4" -tempfile = "3.5.0" -tracing = "0.1.37" -tracing-error = "0.2" -tracing-flame = "0.2.0" -tracing-subscriber = "0.3.17" -ureq = "2" -walkdir = "2" - -[build-dependencies] -clap = { version = "4.2.4", features = ["derive", "string"] } -clap_complete = "4.2.1" -clap_mangen = "0.2.10" - -[features] -default = ["embed-fonts"] - -# Embeds some fonts into the binary: -# - For text: Linux Libertine, New Computer Modern -# - For math: New Computer Modern Math -# - For code: Deja Vu Sans Mono -embed-fonts = [] diff --git a/cli/build.rs b/cli/build.rs deleted file mode 100644 index 86325e1d..00000000 --- a/cli/build.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::env; -use std::fs::{create_dir_all, File}; -use std::path::Path; -use std::process::Command; - -use clap::{CommandFactory, ValueEnum}; -use clap_complete::{generate_to, Shell}; -use clap_mangen::Man; - -#[path = "src/args.rs"] -#[allow(dead_code)] -mod args; - -fn main() { - println!("cargo:rerun-if-env-changed=TYPST_VERSION"); - println!("cargo:rerun-if-env-changed=GEN_ARTIFACTS"); - - if option_env!("TYPST_VERSION").is_none() { - println!("cargo:rustc-env=TYPST_VERSION={}", typst_version()); - } - - if let Some(dir) = env::var_os("GEN_ARTIFACTS") { - let out = &Path::new(&dir); - create_dir_all(out).unwrap(); - let cmd = &mut args::CliArguments::command(); - - Man::new(cmd.clone()) - .render(&mut File::create(out.join("typst.1")).unwrap()) - .unwrap(); - - for subcmd in cmd.get_subcommands() { - let name = format!("typst-{}", subcmd.get_name()); - Man::new(subcmd.clone().name(&name)) - .render(&mut File::create(out.join(format!("{name}.1"))).unwrap()) - .unwrap(); - } - - for shell in Shell::value_variants() { - generate_to(*shell, cmd, "typst", out).unwrap(); - } - } -} - -/// Also used by `args.rs`. -fn typst_version() -> String { - if let Some(version) = option_env!("TYPST_VERSION") { - return version.to_owned(); - } - - let pkg = env!("CARGO_PKG_VERSION"); - let hash = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout.get(..8)?.into()).ok()) - .unwrap_or_else(|| "unknown hash".into()); - - format!("{pkg} ({hash})") -} diff --git a/cli/src/args.rs b/cli/src/args.rs deleted file mode 100644 index 7fdb041b..00000000 --- a/cli/src/args.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::fmt::{self, Display, Formatter}; -use std::path::PathBuf; - -use clap::{ArgAction, Parser, Subcommand, ValueEnum}; - -/// typst creates PDF files from .typ files -#[derive(Debug, Clone, Parser)] -#[clap(name = "typst", version = crate::typst_version(), author)] -pub struct CliArguments { - /// The typst command to run - #[command(subcommand)] - pub command: Command, - - /// Sets the level of logging verbosity: - /// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace - #[clap(short, long, action = ArgAction::Count)] - pub verbosity: u8, -} - -/// What to do. -#[derive(Debug, Clone, Subcommand)] -#[command()] -pub enum Command { - /// Compiles the input file into a PDF file - #[command(visible_alias = "c")] - Compile(CompileCommand), - - /// Watches the input file and recompiles on changes - #[command(visible_alias = "w")] - Watch(CompileCommand), - - /// List all discovered fonts in system and custom font paths - Fonts(FontsCommand), -} - -/// Compiles the input file into a PDF file -#[derive(Debug, Clone, Parser)] -pub struct CompileCommand { - /// Path to input Typst file - pub input: PathBuf, - - /// Path to output PDF file or PNG file(s) - pub output: Option, - - /// Configure the project root - #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] - pub root: Option, - - /// Add additional directories to search for fonts - #[clap( - long = "font-path", - env = "TYPST_FONT_PATHS", - value_name = "DIR", - action = ArgAction::Append, - )] - pub font_paths: Vec, - - /// Opens the output file after compilation using the default PDF viewer - #[arg(long = "open")] - pub open: Option>, - - /// The PPI to use if exported as PNG - #[arg(long = "ppi", default_value_t = 144.0)] - pub ppi: f32, - - /// In which format to emit diagnostics - #[clap( - long, - default_value_t = DiagnosticFormat::Human, - value_parser = clap::value_parser!(DiagnosticFormat) - )] - pub diagnostic_format: DiagnosticFormat, - - /// Produces a flamegraph of the compilation process - #[arg(long = "flamegraph", value_name = "OUTPUT_SVG")] - pub flamegraph: Option>, -} - -impl CompileCommand { - /// The output path. - pub fn output(&self) -> PathBuf { - self.output - .clone() - .unwrap_or_else(|| self.input.with_extension("pdf")) - } -} - -/// List all discovered fonts in system and custom font paths -#[derive(Debug, Clone, Parser)] -pub struct FontsCommand { - /// Add additional directories to search for fonts - #[clap( - long = "font-path", - env = "TYPST_FONT_PATHS", - value_name = "DIR", - action = ArgAction::Append, - )] - pub font_paths: Vec, - - /// Also list style variants of each font family - #[arg(long)] - pub variants: bool, -} - -/// Which format to use for diagnostics. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] -pub enum DiagnosticFormat { - Human, - Short, -} - -impl Display for DiagnosticFormat { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.to_possible_value() - .expect("no values are skipped") - .get_name() - .fmt(f) - } -} diff --git a/cli/src/compile.rs b/cli/src/compile.rs deleted file mode 100644 index 3250202b..00000000 --- a/cli/src/compile.rs +++ /dev/null @@ -1,239 +0,0 @@ -use std::fs; -use std::path::Path; - -use codespan_reporting::diagnostic::{Diagnostic, Label}; -use codespan_reporting::term::{self, termcolor}; -use termcolor::{ColorChoice, StandardStream}; -use typst::diag::{bail, SourceError, StrResult}; -use typst::doc::Document; -use typst::eval::eco_format; -use typst::file::FileId; -use typst::geom::Color; -use typst::syntax::Source; -use typst::World; - -use crate::args::{CompileCommand, DiagnosticFormat}; -use crate::watch::Status; -use crate::world::SystemWorld; -use crate::{color_stream, set_failed}; - -type CodespanResult = Result; -type CodespanError = codespan_reporting::files::Error; - -/// Execute a compilation command. -pub fn compile(mut command: CompileCommand) -> StrResult<()> { - let mut world = SystemWorld::new(&command)?; - compile_once(&mut world, &mut command, false)?; - Ok(()) -} - -/// Compile a single time. -/// -/// Returns whether it compiled without errors. -#[tracing::instrument(skip_all)] -pub fn compile_once( - world: &mut SystemWorld, - command: &mut CompileCommand, - watching: bool, -) -> StrResult<()> { - tracing::info!("Starting compilation"); - - let start = std::time::Instant::now(); - if watching { - Status::Compiling.print(command).unwrap(); - } - - // Reset everything and ensure that the main file is still present. - world.reset(); - world.source(world.main()).map_err(|err| err.to_string())?; - - let result = typst::compile(world); - let duration = start.elapsed(); - - match result { - // Export the PDF / PNG. - Ok(document) => { - export(&document, command)?; - - tracing::info!("Compilation succeeded in {duration:?}"); - if watching { - Status::Success(duration).print(command).unwrap(); - } - - if let Some(open) = command.open.take() { - open_file(open.as_deref(), &command.output())?; - } - } - - // Print diagnostics. - Err(errors) => { - set_failed(); - tracing::info!("Compilation failed"); - - if watching { - Status::Error.print(command).unwrap(); - } - - print_diagnostics(world, *errors, command.diagnostic_format) - .map_err(|_| "failed to print diagnostics")?; - } - } - - Ok(()) -} - -/// Export into the target format. -fn export(document: &Document, command: &CompileCommand) -> StrResult<()> { - match command.output().extension() { - Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command), - _ => export_pdf(document, command), - } -} - -/// Export to a PDF. -fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { - let output = command.output(); - let buffer = typst::export::pdf(document); - fs::write(output, buffer).map_err(|_| "failed to write PDF file")?; - Ok(()) -} - -/// Export to one or multiple PNGs. -fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> { - // Determine whether we have a `{n}` numbering. - let output = command.output(); - let string = output.to_str().unwrap_or_default(); - let numbered = string.contains("{n}"); - if !numbered && document.pages.len() > 1 { - bail!("cannot export multiple PNGs without `{{n}}` in output path"); - } - - // Find a number width that accommodates all pages. For instance, the - // first page should be numbered "001" if there are between 100 and - // 999 pages. - let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize; - let mut storage; - - for (i, frame) in document.pages.iter().enumerate() { - let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE); - let path = if numbered { - storage = string.replace("{n}", &format!("{:0width$}", i + 1)); - Path::new(&storage) - } else { - output.as_path() - }; - pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; - } - - Ok(()) -} - -/// Opens the given file using: -/// - The default file viewer if `open` is `None`. -/// - The given viewer provided by `open` if it is `Some`. -fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> { - if let Some(app) = open { - open::with_in_background(path, app); - } else { - open::that_in_background(path); - } - - Ok(()) -} - -/// Print diagnostic messages to the terminal. -fn print_diagnostics( - world: &SystemWorld, - errors: Vec, - diagnostic_format: DiagnosticFormat, -) -> Result<(), codespan_reporting::files::Error> { - let mut w = match diagnostic_format { - DiagnosticFormat::Human => color_stream(), - DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never), - }; - - let mut config = term::Config { tab_width: 2, ..Default::default() }; - if diagnostic_format == DiagnosticFormat::Short { - config.display_style = term::DisplayStyle::Short; - } - - for error in errors { - // The main diagnostic. - let diag = Diagnostic::error() - .with_message(error.message) - .with_notes( - error - .hints - .iter() - .map(|e| (eco_format!("hint: {e}")).into()) - .collect(), - ) - .with_labels(vec![Label::primary(error.span.id(), error.span.range(world))]); - - term::emit(&mut w, &config, world, &diag)?; - - // Stacktrace-like helper diagnostics. - for point in error.trace { - let message = point.v.to_string(); - let help = Diagnostic::help().with_message(message).with_labels(vec![ - Label::primary(point.span.id(), point.span.range(world)), - ]); - - term::emit(&mut w, &config, world, &help)?; - } - } - - Ok(()) -} - -impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { - type FileId = FileId; - type Name = FileId; - type Source = Source; - - fn name(&'a self, id: FileId) -> CodespanResult { - Ok(id) - } - - fn source(&'a self, id: FileId) -> CodespanResult { - Ok(self.lookup(id)) - } - - fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult { - let source = self.lookup(id); - source - .byte_to_line(given) - .ok_or_else(|| CodespanError::IndexTooLarge { - given, - max: source.len_bytes(), - }) - } - - fn line_range( - &'a self, - id: FileId, - given: usize, - ) -> CodespanResult> { - let source = self.lookup(id); - source - .line_to_range(given) - .ok_or_else(|| CodespanError::LineTooLarge { given, max: source.len_lines() }) - } - - fn column_number( - &'a self, - id: FileId, - _: usize, - given: usize, - ) -> CodespanResult { - let source = self.lookup(id); - source.byte_to_column(given).ok_or_else(|| { - let max = source.len_bytes(); - if given <= max { - CodespanError::InvalidCharBoundary { given } - } else { - CodespanError::IndexTooLarge { given, max } - } - }) - } -} diff --git a/cli/src/fonts.rs b/cli/src/fonts.rs deleted file mode 100644 index 75835741..00000000 --- a/cli/src/fonts.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::cell::OnceCell; -use std::env; -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; - -use memmap2::Mmap; -use typst::diag::StrResult; -use typst::font::{Font, FontBook, FontInfo, FontVariant}; -use typst::util::Bytes; -use walkdir::WalkDir; - -use crate::args::FontsCommand; - -/// Execute a font listing command. -pub fn fonts(command: FontsCommand) -> StrResult<()> { - let mut searcher = FontSearcher::new(); - searcher.search(&command.font_paths); - - for (name, infos) in searcher.book.families() { - println!("{name}"); - if command.variants { - for info in infos { - let FontVariant { style, weight, stretch } = info.variant; - println!("- Style: {style:?}, Weight: {weight:?}, Stretch: {stretch:?}"); - } - } - } - - Ok(()) -} - -/// Searches for fonts. -pub struct FontSearcher { - /// Metadata about all discovered fonts. - pub book: FontBook, - /// Slots that the fonts are loaded into. - pub fonts: Vec, -} - -/// Holds details about the location of a font and lazily the font itself. -pub struct FontSlot { - /// The path at which the font can be found on the system. - path: PathBuf, - /// The index of the font in its collection. Zero if the path does not point - /// to a collection. - index: u32, - /// The lazily loaded font. - font: OnceCell>, -} - -impl FontSlot { - /// Get the font for this slot. - pub fn get(&self) -> Option { - self.font - .get_or_init(|| { - let data = fs::read(&self.path).ok()?.into(); - Font::new(data, self.index) - }) - .clone() - } -} - -impl FontSearcher { - /// Create a new, empty system searcher. - pub fn new() -> Self { - Self { book: FontBook::new(), fonts: vec![] } - } - - /// Search everything that is available. - pub fn search(&mut self, font_paths: &[PathBuf]) { - self.search_system(); - - #[cfg(feature = "embed-fonts")] - self.add_embedded(); - - for path in font_paths { - self.search_dir(path) - } - } - - /// Add fonts that are embedded in the binary. - #[cfg(feature = "embed-fonts")] - fn add_embedded(&mut self) { - let mut process = |bytes: &'static [u8]| { - let buffer = Bytes::from_static(bytes); - for (i, font) in Font::iter(buffer).enumerate() { - self.book.push(font.info().clone()); - self.fonts.push(FontSlot { - path: PathBuf::new(), - index: i as u32, - font: OnceCell::from(Some(font)), - }); - } - }; - - macro_rules! add { - ($filename:literal) => { - process(include_bytes!(concat!("../../assets/fonts/", $filename,))); - }; - } - - // Embed default fonts. - add!("LinLibertine_R.ttf"); - add!("LinLibertine_RB.ttf"); - add!("LinLibertine_RBI.ttf"); - add!("LinLibertine_RI.ttf"); - add!("NewCMMath-Book.otf"); - add!("NewCMMath-Regular.otf"); - add!("NewCM10-Regular.otf"); - add!("NewCM10-Bold.otf"); - add!("NewCM10-Italic.otf"); - add!("NewCM10-BoldItalic.otf"); - add!("DejaVuSansMono.ttf"); - add!("DejaVuSansMono-Bold.ttf"); - add!("DejaVuSansMono-Oblique.ttf"); - add!("DejaVuSansMono-BoldOblique.ttf"); - } - - /// Search for fonts in the linux system font directories. - fn search_system(&mut self) { - if cfg!(target_os = "macos") { - self.search_dir("/Library/Fonts"); - self.search_dir("/Network/Library/Fonts"); - self.search_dir("/System/Library/Fonts"); - } else if cfg!(unix) { - self.search_dir("/usr/share/fonts"); - self.search_dir("/usr/local/share/fonts"); - } else if cfg!(windows) { - self.search_dir( - env::var_os("WINDIR") - .map(PathBuf::from) - .unwrap_or_else(|| "C:\\Windows".into()) - .join("Fonts"), - ); - - if let Some(roaming) = dirs::config_dir() { - self.search_dir(roaming.join("Microsoft\\Windows\\Fonts")); - } - - if let Some(local) = dirs::cache_dir() { - self.search_dir(local.join("Microsoft\\Windows\\Fonts")); - } - } - - if let Some(dir) = dirs::font_dir() { - self.search_dir(dir); - } - } - - /// Search for all fonts in a directory recursively. - fn search_dir(&mut self, path: impl AsRef) { - for entry in WalkDir::new(path) - .follow_links(true) - .sort_by(|a, b| a.file_name().cmp(b.file_name())) - .into_iter() - .filter_map(|e| e.ok()) - { - let path = entry.path(); - if matches!( - path.extension().and_then(|s| s.to_str()), - Some("ttf" | "otf" | "TTF" | "OTF" | "ttc" | "otc" | "TTC" | "OTC"), - ) { - self.search_file(path); - } - } - } - - /// Index the fonts in the file at the given path. - fn search_file(&mut self, path: &Path) { - if let Ok(file) = File::open(path) { - if let Ok(mmap) = unsafe { Mmap::map(&file) } { - for (i, info) in FontInfo::iter(&mmap).enumerate() { - self.book.push(info); - self.fonts.push(FontSlot { - path: path.into(), - index: i as u32, - font: OnceCell::new(), - }); - } - } - } - } -} diff --git a/cli/src/main.rs b/cli/src/main.rs deleted file mode 100644 index 425d05fd..00000000 --- a/cli/src/main.rs +++ /dev/null @@ -1,79 +0,0 @@ -mod args; -mod compile; -mod fonts; -mod package; -mod tracing; -mod watch; -mod world; - -use std::cell::Cell; -use std::env; -use std::io::{self, IsTerminal, Write}; -use std::process::ExitCode; - -use clap::Parser; -use codespan_reporting::term::{self, termcolor}; -use termcolor::{ColorChoice, WriteColor}; - -use crate::args::{CliArguments, Command}; - -thread_local! { - /// The CLI's exit code. - static EXIT: Cell = Cell::new(ExitCode::SUCCESS); -} - -/// Entry point. -fn main() -> ExitCode { - let arguments = CliArguments::parse(); - let _guard = match crate::tracing::setup_tracing(&arguments) { - Ok(guard) => guard, - Err(err) => { - eprintln!("failed to initialize tracing {}", err); - None - } - }; - - let res = match arguments.command { - Command::Compile(command) => crate::compile::compile(command), - Command::Watch(command) => crate::watch::watch(command), - Command::Fonts(command) => crate::fonts::fonts(command), - }; - - if let Err(msg) = res { - set_failed(); - print_error(&msg).expect("failed to print error"); - } - - EXIT.with(|cell| cell.get()) -} - -/// Ensure a failure exit code. -fn set_failed() { - EXIT.with(|cell| cell.set(ExitCode::FAILURE)); -} - -/// Print an application-level error (independent from a source file). -fn print_error(msg: &str) -> io::Result<()> { - let mut w = color_stream(); - let styles = term::Styles::default(); - - w.set_color(&styles.header_error)?; - write!(w, "error")?; - - w.reset()?; - writeln!(w, ": {msg}.") -} - -/// Get stderr with color support if desirable. -fn color_stream() -> termcolor::StandardStream { - termcolor::StandardStream::stderr(if std::io::stderr().is_terminal() { - ColorChoice::Auto - } else { - ColorChoice::Never - }) -} - -/// Used by `args.rs`. -fn typst_version() -> &'static str { - env!("TYPST_VERSION") -} diff --git a/cli/src/package.rs b/cli/src/package.rs deleted file mode 100644 index 6853796b..00000000 --- a/cli/src/package.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::fs; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -use codespan_reporting::term::{self, termcolor}; -use termcolor::WriteColor; -use typst::diag::{PackageError, PackageResult}; -use typst::file::PackageSpec; - -use super::color_stream; - -/// Make a package available in the on-disk cache. -pub fn prepare_package(spec: &PackageSpec) -> PackageResult { - let subdir = - format!("typst/packages/{}/{}-{}", spec.namespace, spec.name, spec.version); - - if let Some(data_dir) = dirs::data_dir() { - let dir = data_dir.join(&subdir); - if dir.exists() { - return Ok(dir); - } - } - - if let Some(cache_dir) = dirs::cache_dir() { - let dir = cache_dir.join(&subdir); - - // Download from network if it doesn't exist yet. - if spec.namespace == "preview" && !dir.exists() { - download_package(spec, &dir)?; - } - - if dir.exists() { - return Ok(dir); - } - } - - Err(PackageError::NotFound(spec.clone())) -} - -/// Download a package over the network. -fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { - // The `@preview` namespace is the only namespace that supports on-demand - // fetching. - assert_eq!(spec.namespace, "preview"); - - let url = format!( - "https://packages.typst.org/preview/{}-{}.tar.gz", - spec.name, spec.version - ); - - print_downloading(spec).unwrap(); - let reader = match ureq::get(&url).call() { - Ok(response) => response.into_reader(), - Err(ureq::Error::Status(404, _)) => { - return Err(PackageError::NotFound(spec.clone())) - } - Err(_) => return Err(PackageError::NetworkFailed), - }; - - let decompressed = flate2::read::GzDecoder::new(reader); - tar::Archive::new(decompressed).unpack(package_dir).map_err(|_| { - fs::remove_dir_all(package_dir).ok(); - PackageError::MalformedArchive - }) -} - -/// Print that a package downloading is happening. -fn print_downloading(spec: &PackageSpec) -> io::Result<()> { - let mut w = color_stream(); - let styles = term::Styles::default(); - - w.set_color(&styles.header_help)?; - write!(w, "downloading")?; - - w.reset()?; - writeln!(w, " {spec}") -} diff --git a/cli/src/tracing.rs b/cli/src/tracing.rs deleted file mode 100644 index 80c2ff65..00000000 --- a/cli/src/tracing.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::fs::File; -use std::io::{self, BufReader, BufWriter, Seek, SeekFrom}; -use std::path::PathBuf; - -use inferno::flamegraph::Options; -use tracing::metadata::LevelFilter; -use tracing_error::ErrorLayer; -use tracing_flame::{FlameLayer, FlushGuard}; -use tracing_subscriber::fmt; -use tracing_subscriber::prelude::*; - -use crate::args::{CliArguments, Command}; - -/// Initializes the tracing system and returns a guard that will flush the -/// flamegraph to disk when dropped. -pub fn setup_tracing(args: &CliArguments) -> io::Result> { - let flamegraph = match &args.command { - Command::Compile(command) => command.flamegraph.as_ref(), - Command::Watch(command) if command.flamegraph.is_some() => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "cannot use --flamegraph with watch command", - )); - } - _ => None, - }; - - // Short circuit if we don't need to initialize flamegraph or debugging. - if flamegraph.is_none() && args.verbosity == 0 { - tracing_subscriber::fmt() - .without_time() - .with_max_level(level_filter(args)) - .init(); - - return Ok(None); - } - - // Build the FMT layer printing to the console. - let fmt_layer = fmt::Layer::default().without_time().with_filter(level_filter(args)); - - // Error layer for building backtraces - let error_layer = ErrorLayer::default(); - - // Build the registry. - let registry = tracing_subscriber::registry().with(fmt_layer).with(error_layer); - - let Some(path) = flamegraph else { - registry.init(); - return Ok(None); - }; - - // Create a temporary file to store the flamegraph data. - let temp_file = tempfile::tempfile()?; - let writer = BufWriter::new(temp_file.try_clone()?); - - // Build the flamegraph layer. - let flame_layer = FlameLayer::new(writer) - .with_empty_samples(false) - .with_threads_collapsed(true) - .with_module_path(false) - .with_file_and_line(true); - let flush_guard = flame_layer.flush_on_drop(); - - // Build the subscriber. - registry.with(flame_layer).init(); - - tracing::warn!( - "Flamegraph is enabled, this can create a large temporary \ - file and slow down the compilation process." - ); - - Ok(Some(TracingGuard { - flush_guard: Some(flush_guard), - temp_file, - output_svg: path.clone().unwrap_or_else(|| "flamegraph.svg".into()), - })) -} - -/// Returns the log level filter for the given verbosity level. -fn level_filter(args: &CliArguments) -> LevelFilter { - match args.verbosity { - 0 => LevelFilter::OFF, - 1 => LevelFilter::WARN, - 2 => LevelFilter::INFO, - 3 => LevelFilter::DEBUG, - _ => LevelFilter::TRACE, - } -} - -/// Will flush the flamegraph to disk when dropped. -struct TracingGuard { - flush_guard: Option>>, - temp_file: File, - output_svg: PathBuf, -} - -impl TracingGuard { - fn finish(&mut self) -> io::Result<()> { - if self.flush_guard.is_none() { - return Ok(()); - } - - tracing::info!("Flushing tracing flamegraph..."); - - // At this point, we're done tracing, so we can drop the guard. - // This will flush the tracing output to disk. - // We can then read the file and generate the flamegraph. - drop(self.flush_guard.take()); - - // Reset the file pointer to the beginning. - self.temp_file.seek(SeekFrom::Start(0))?; - - // Create the readers and writers. - let reader = BufReader::new(&mut self.temp_file); - let output = BufWriter::new(File::create(&self.output_svg)?); - - // Create the options: default in flame chart mode - let mut options = Options::default(); - options.flame_chart = true; - - inferno::flamegraph::from_reader(&mut options, reader, output) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - Ok(()) - } -} - -impl Drop for TracingGuard { - fn drop(&mut self) { - if !std::thread::panicking() { - if let Err(e) = self.finish() { - // Since we are finished, we cannot rely on tracing to log the - // error. - eprintln!("failed to flush tracing flamegraph: {e}"); - } - } - } -} diff --git a/cli/src/watch.rs b/cli/src/watch.rs deleted file mode 100644 index 2ad73f85..00000000 --- a/cli/src/watch.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::collections::HashSet; -use std::io::{self, IsTerminal, Write}; -use std::path::{Path, PathBuf}; - -use codespan_reporting::term::{self, termcolor}; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use same_file::is_same_file; -use termcolor::WriteColor; -use typst::diag::StrResult; -use typst::eval::eco_format; - -use crate::args::CompileCommand; -use crate::color_stream; -use crate::compile::compile_once; -use crate::world::SystemWorld; - -/// Execute a watching compilation command. -pub fn watch(mut command: CompileCommand) -> StrResult<()> { - // Create the world that serves sources, files, and fonts. - let mut world = SystemWorld::new(&command)?; - - // Perform initial compilation. - compile_once(&mut world, &mut command, true)?; - - // Setup file watching. - let (tx, rx) = std::sync::mpsc::channel(); - let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()) - .map_err(|_| "failed to setup file watching")?; - - // Watch all the files that are used by the input file and its dependencies. - watch_dependencies(&mut world, &mut watcher, HashSet::new())?; - - // Handle events. - let timeout = std::time::Duration::from_millis(100); - let output = command.output(); - loop { - let mut recompile = false; - for event in rx - .recv() - .into_iter() - .chain(std::iter::from_fn(|| rx.recv_timeout(timeout).ok())) - { - let event = event.map_err(|_| "failed to watch directory")?; - recompile |= is_event_relevant(&event, &output); - } - - if recompile { - // Retrieve the dependencies of the last compilation. - let previous: HashSet = - world.dependencies().map(ToOwned::to_owned).collect(); - - // Recompile. - compile_once(&mut world, &mut command, true)?; - comemo::evict(10); - - // Adjust the watching. - watch_dependencies(&mut world, &mut watcher, previous)?; - } - } -} - -/// Adjust the file watching. Watches all new dependencies and unwatches -/// all `previous` dependencies that are not relevant anymore. -#[tracing::instrument(skip_all)] -fn watch_dependencies( - world: &mut SystemWorld, - watcher: &mut dyn Watcher, - mut previous: HashSet, -) -> StrResult<()> { - // Watch new paths that weren't watched yet. - for path in world.dependencies() { - let watched = previous.remove(path); - if path.exists() && !watched { - tracing::info!("Watching {}", path.display()); - watcher - .watch(path, RecursiveMode::NonRecursive) - .map_err(|_| eco_format!("failed to watch {path:?}"))?; - } - } - - // Unwatch old paths that don't need to be watched anymore. - for path in previous { - tracing::info!("Unwatching {}", path.display()); - watcher.unwatch(&path).ok(); - } - - Ok(()) -} - -/// Whether a watch event is relevant for compilation. -fn is_event_relevant(event: ¬ify::Event, output: &Path) -> bool { - // Never recompile because the output file changed. - if event - .paths - .iter() - .all(|path| is_same_file(path, output).unwrap_or(false)) - { - return false; - } - - match &event.kind { - notify::EventKind::Any => true, - notify::EventKind::Access(_) => false, - notify::EventKind::Create(_) => true, - notify::EventKind::Modify(kind) => match kind { - notify::event::ModifyKind::Any => true, - notify::event::ModifyKind::Data(_) => true, - notify::event::ModifyKind::Metadata(_) => false, - notify::event::ModifyKind::Name(_) => true, - notify::event::ModifyKind::Other => false, - }, - notify::EventKind::Remove(_) => true, - notify::EventKind::Other => false, - } -} - -/// The status in which the watcher can be. -pub enum Status { - Compiling, - Success(std::time::Duration), - Error, -} - -impl Status { - /// Clear the terminal and render the status message. - pub fn print(&self, command: &CompileCommand) -> io::Result<()> { - let output = command.output(); - let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); - let color = self.color(); - - let mut w = color_stream(); - if std::io::stderr().is_terminal() { - // Clear the terminal. - let esc = 27 as char; - write!(w, "{esc}c{esc}[1;1H")?; - } - - w.set_color(&color)?; - write!(w, "watching")?; - w.reset()?; - writeln!(w, " {}", command.input.display())?; - - w.set_color(&color)?; - write!(w, "writing to")?; - w.reset()?; - writeln!(w, " {}", output.display())?; - - writeln!(w)?; - writeln!(w, "[{timestamp}] {}", self.message())?; - writeln!(w)?; - - w.flush() - } - - fn message(&self) -> String { - match self { - Self::Compiling => "compiling ...".into(), - Self::Success(duration) => format!("compiled successfully in {duration:.2?}"), - Self::Error => "compiled with errors".into(), - } - } - - fn color(&self) -> termcolor::ColorSpec { - let styles = term::Styles::default(); - match self { - Self::Error => styles.header_error, - _ => styles.header_note, - } - } -} diff --git a/cli/src/world.rs b/cli/src/world.rs deleted file mode 100644 index f09a3f6c..00000000 --- a/cli/src/world.rs +++ /dev/null @@ -1,256 +0,0 @@ -use std::cell::{OnceCell, RefCell, RefMut}; -use std::collections::HashMap; -use std::fs; -use std::hash::Hash; -use std::path::{Path, PathBuf}; - -use chrono::Datelike; -use comemo::Prehashed; -use same_file::Handle; -use siphasher::sip128::{Hasher128, SipHasher13}; -use typst::diag::{FileError, FileResult, StrResult}; -use typst::eval::{eco_format, Datetime, Library}; -use typst::file::FileId; -use typst::font::{Font, FontBook}; -use typst::syntax::Source; -use typst::util::{Bytes, PathExt}; -use typst::World; - -use crate::args::CompileCommand; -use crate::fonts::{FontSearcher, FontSlot}; -use crate::package::prepare_package; - -/// A world that provides access to the operating system. -pub struct SystemWorld { - /// The root relative to which absolute paths are resolved. - root: PathBuf, - /// The input path. - main: FileId, - /// Typst's standard library. - library: Prehashed, - /// Metadata about discovered fonts. - book: Prehashed, - /// Locations of and storage for lazily loaded fonts. - fonts: Vec, - /// Maps package-path combinations to canonical hashes. All package-path - /// combinations that point to the same file are mapped to the same hash. To - /// be used in conjunction with `paths`. - hashes: RefCell>>, - /// Maps canonical path hashes to source files and buffers. - paths: RefCell>, - /// The current date if requested. This is stored here to ensure it is - /// always the same within one compilation. Reset between compilations. - today: OnceCell>, -} - -impl SystemWorld { - /// Create a new system world. - pub fn new(command: &CompileCommand) -> StrResult { - let mut searcher = FontSearcher::new(); - searcher.search(&command.font_paths); - - // Resolve the system-global input path. - let system_input = command.input.canonicalize().map_err(|_| { - eco_format!("input file not found (searched at {})", command.input.display()) - })?; - - // Resolve the system-global root directory. - let root = { - let path = command - .root - .as_deref() - .or_else(|| system_input.parent()) - .unwrap_or(Path::new(".")); - path.canonicalize().map_err(|_| { - eco_format!("root directory not found (searched at {})", path.display()) - })? - }; - - // Resolve the input path within the project. - let project_input = system_input - .strip_prefix(&root) - .map(|path| Path::new("/").join(path)) - .map_err(|_| "input file must be contained in project root")?; - - Ok(Self { - root, - main: FileId::new(None, &project_input), - library: Prehashed::new(typst_library::build()), - book: Prehashed::new(searcher.book), - fonts: searcher.fonts, - hashes: RefCell::default(), - paths: RefCell::default(), - today: OnceCell::new(), - }) - } - - /// The id of the main source file. - pub fn main(&self) -> FileId { - self.main - } - - /// Return all paths the last compilation depended on. - pub fn dependencies(&mut self) -> impl Iterator { - self.paths.get_mut().values().map(|slot| slot.system_path.as_path()) - } - - /// Reset the compilation state in preparation of a new compilation. - pub fn reset(&mut self) { - self.hashes.borrow_mut().clear(); - self.paths.borrow_mut().clear(); - self.today.take(); - } - - /// Lookup a source file by id. - #[track_caller] - pub fn lookup(&self, id: FileId) -> Source { - self.source(id).expect("file id does not point to any source file") - } -} - -impl World for SystemWorld { - fn library(&self) -> &Prehashed { - &self.library - } - - fn book(&self) -> &Prehashed { - &self.book - } - - fn main(&self) -> Source { - self.source(self.main).unwrap() - } - - fn source(&self, id: FileId) -> FileResult { - self.slot(id)?.source() - } - - fn file(&self, id: FileId) -> FileResult { - self.slot(id)?.file() - } - - fn font(&self, index: usize) -> Option { - self.fonts[index].get() - } - - fn today(&self, offset: Option) -> Option { - *self.today.get_or_init(|| { - let naive = match offset { - None => chrono::Local::now().naive_local(), - Some(o) => (chrono::Utc::now() + chrono::Duration::hours(o)).naive_utc(), - }; - - Datetime::from_ymd( - naive.year(), - naive.month().try_into().ok()?, - naive.day().try_into().ok()?, - ) - }) - } -} - -impl SystemWorld { - /// Access the canonical slot for the given file id. - #[tracing::instrument(skip_all)] - fn slot(&self, id: FileId) -> FileResult> { - let mut system_path = PathBuf::new(); - let hash = self - .hashes - .borrow_mut() - .entry(id) - .or_insert_with(|| { - // Determine the root path relative to which the file path - // will be resolved. - let root = match id.package() { - Some(spec) => prepare_package(spec)?, - None => self.root.clone(), - }; - - // Join the path to the root. If it tries to escape, deny - // access. Note: It can still escape via symlinks. - system_path = - root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?; - - PathHash::new(&system_path) - }) - .clone()?; - - Ok(RefMut::map(self.paths.borrow_mut(), |paths| { - paths.entry(hash).or_insert_with(|| PathSlot { - id, - // This will only trigger if the `or_insert_with` above also - // triggered. - system_path, - source: OnceCell::new(), - buffer: OnceCell::new(), - }) - })) - } -} - -/// Holds canonical data for all paths pointing to the same entity. -/// -/// Both fields can be populated if the file is both imported and read(). -struct PathSlot { - /// The slot's canonical file id. - id: FileId, - /// The slot's path on the system. - system_path: PathBuf, - /// The lazily loaded source file for a path hash. - source: OnceCell>, - /// The lazily loaded buffer for a path hash. - buffer: OnceCell>, -} - -impl PathSlot { - fn source(&self) -> FileResult { - self.source - .get_or_init(|| { - let buf = read(&self.system_path)?; - let text = decode_utf8(buf)?; - Ok(Source::new(self.id, text)) - }) - .clone() - } - - fn file(&self) -> FileResult { - self.buffer - .get_or_init(|| read(&self.system_path).map(Bytes::from)) - .clone() - } -} - -/// A hash that is the same for all paths pointing to the same entity. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -struct PathHash(u128); - -impl PathHash { - fn new(path: &Path) -> FileResult { - let f = |e| FileError::from_io(e, path); - let handle = Handle::from_path(path).map_err(f)?; - let mut state = SipHasher13::new(); - handle.hash(&mut state); - Ok(Self(state.finish128().as_u128())) - } -} - -/// Read a file. -fn read(path: &Path) -> FileResult> { - let f = |e| FileError::from_io(e, path); - if fs::metadata(path).map_err(f)?.is_dir() { - Err(FileError::IsDirectory) - } else { - fs::read(path).map_err(f) - } -} - -/// Decode UTF-8 with an optional BOM. -fn decode_utf8(buf: Vec) -> FileResult { - Ok(if buf.starts_with(b"\xef\xbb\xbf") { - // Remove UTF-8 BOM. - std::str::from_utf8(&buf[3..])?.into() - } else { - // Assume UTF-8. - String::from_utf8(buf)? - }) -} diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml new file mode 100644 index 00000000..e77cbc2a --- /dev/null +++ b/crates/typst-cli/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "typst-cli" +description = "The command line interface for Typst." +categories = ["compilers", "command-line-utilities"] +keywords = ["typst", "cli"] +version.workspace = true +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true + +[[bin]] +name = "typst" +path = "src/main.rs" +test = false +doctest = false +bench = false +doc = false + +[dependencies] +typst = { path = "../typst" } +typst-library = { path = "../typst-library" } +chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] } +clap = { version = "4.2.4", features = ["derive", "env"] } +codespan-reporting = "0.11" +comemo = "0.3" +dirs = "5" +flate2 = "1" +inferno = "0.11.15" +memmap2 = "0.5" +notify = "5" +once_cell = "1" +open = "4.0.2" +same-file = "1" +siphasher = "0.3" +tar = "0.4" +tempfile = "3.5.0" +tracing = "0.1.37" +tracing-error = "0.2" +tracing-flame = "0.2.0" +tracing-subscriber = "0.3.17" +ureq = "2" +walkdir = "2" + +[build-dependencies] +clap = { version = "4.2.4", features = ["derive", "string"] } +clap_complete = "4.2.1" +clap_mangen = "0.2.10" + +[features] +default = ["embed-fonts"] + +# Embeds some fonts into the binary: +# - For text: Linux Libertine, New Computer Modern +# - For math: New Computer Modern Math +# - For code: Deja Vu Sans Mono +embed-fonts = [] diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs new file mode 100644 index 00000000..86325e1d --- /dev/null +++ b/crates/typst-cli/build.rs @@ -0,0 +1,60 @@ +use std::env; +use std::fs::{create_dir_all, File}; +use std::path::Path; +use std::process::Command; + +use clap::{CommandFactory, ValueEnum}; +use clap_complete::{generate_to, Shell}; +use clap_mangen::Man; + +#[path = "src/args.rs"] +#[allow(dead_code)] +mod args; + +fn main() { + println!("cargo:rerun-if-env-changed=TYPST_VERSION"); + println!("cargo:rerun-if-env-changed=GEN_ARTIFACTS"); + + if option_env!("TYPST_VERSION").is_none() { + println!("cargo:rustc-env=TYPST_VERSION={}", typst_version()); + } + + if let Some(dir) = env::var_os("GEN_ARTIFACTS") { + let out = &Path::new(&dir); + create_dir_all(out).unwrap(); + let cmd = &mut args::CliArguments::command(); + + Man::new(cmd.clone()) + .render(&mut File::create(out.join("typst.1")).unwrap()) + .unwrap(); + + for subcmd in cmd.get_subcommands() { + let name = format!("typst-{}", subcmd.get_name()); + Man::new(subcmd.clone().name(&name)) + .render(&mut File::create(out.join(format!("{name}.1"))).unwrap()) + .unwrap(); + } + + for shell in Shell::value_variants() { + generate_to(*shell, cmd, "typst", out).unwrap(); + } + } +} + +/// Also used by `args.rs`. +fn typst_version() -> String { + if let Some(version) = option_env!("TYPST_VERSION") { + return version.to_owned(); + } + + let pkg = env!("CARGO_PKG_VERSION"); + let hash = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout.get(..8)?.into()).ok()) + .unwrap_or_else(|| "unknown hash".into()); + + format!("{pkg} ({hash})") +} diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs new file mode 100644 index 00000000..7fdb041b --- /dev/null +++ b/crates/typst-cli/src/args.rs @@ -0,0 +1,119 @@ +use std::fmt::{self, Display, Formatter}; +use std::path::PathBuf; + +use clap::{ArgAction, Parser, Subcommand, ValueEnum}; + +/// typst creates PDF files from .typ files +#[derive(Debug, Clone, Parser)] +#[clap(name = "typst", version = crate::typst_version(), author)] +pub struct CliArguments { + /// The typst command to run + #[command(subcommand)] + pub command: Command, + + /// Sets the level of logging verbosity: + /// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace + #[clap(short, long, action = ArgAction::Count)] + pub verbosity: u8, +} + +/// What to do. +#[derive(Debug, Clone, Subcommand)] +#[command()] +pub enum Command { + /// Compiles the input file into a PDF file + #[command(visible_alias = "c")] + Compile(CompileCommand), + + /// Watches the input file and recompiles on changes + #[command(visible_alias = "w")] + Watch(CompileCommand), + + /// List all discovered fonts in system and custom font paths + Fonts(FontsCommand), +} + +/// Compiles the input file into a PDF file +#[derive(Debug, Clone, Parser)] +pub struct CompileCommand { + /// Path to input Typst file + pub input: PathBuf, + + /// Path to output PDF file or PNG file(s) + pub output: Option, + + /// Configure the project root + #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] + pub root: Option, + + /// Add additional directories to search for fonts + #[clap( + long = "font-path", + env = "TYPST_FONT_PATHS", + value_name = "DIR", + action = ArgAction::Append, + )] + pub font_paths: Vec, + + /// Opens the output file after compilation using the default PDF viewer + #[arg(long = "open")] + pub open: Option>, + + /// The PPI to use if exported as PNG + #[arg(long = "ppi", default_value_t = 144.0)] + pub ppi: f32, + + /// In which format to emit diagnostics + #[clap( + long, + default_value_t = DiagnosticFormat::Human, + value_parser = clap::value_parser!(DiagnosticFormat) + )] + pub diagnostic_format: DiagnosticFormat, + + /// Produces a flamegraph of the compilation process + #[arg(long = "flamegraph", value_name = "OUTPUT_SVG")] + pub flamegraph: Option>, +} + +impl CompileCommand { + /// The output path. + pub fn output(&self) -> PathBuf { + self.output + .clone() + .unwrap_or_else(|| self.input.with_extension("pdf")) + } +} + +/// List all discovered fonts in system and custom font paths +#[derive(Debug, Clone, Parser)] +pub struct FontsCommand { + /// Add additional directories to search for fonts + #[clap( + long = "font-path", + env = "TYPST_FONT_PATHS", + value_name = "DIR", + action = ArgAction::Append, + )] + pub font_paths: Vec, + + /// Also list style variants of each font family + #[arg(long)] + pub variants: bool, +} + +/// Which format to use for diagnostics. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] +pub enum DiagnosticFormat { + Human, + Short, +} + +impl Display for DiagnosticFormat { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.to_possible_value() + .expect("no values are skipped") + .get_name() + .fmt(f) + } +} diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs new file mode 100644 index 00000000..3250202b --- /dev/null +++ b/crates/typst-cli/src/compile.rs @@ -0,0 +1,239 @@ +use std::fs; +use std::path::Path; + +use codespan_reporting::diagnostic::{Diagnostic, Label}; +use codespan_reporting::term::{self, termcolor}; +use termcolor::{ColorChoice, StandardStream}; +use typst::diag::{bail, SourceError, StrResult}; +use typst::doc::Document; +use typst::eval::eco_format; +use typst::file::FileId; +use typst::geom::Color; +use typst::syntax::Source; +use typst::World; + +use crate::args::{CompileCommand, DiagnosticFormat}; +use crate::watch::Status; +use crate::world::SystemWorld; +use crate::{color_stream, set_failed}; + +type CodespanResult = Result; +type CodespanError = codespan_reporting::files::Error; + +/// Execute a compilation command. +pub fn compile(mut command: CompileCommand) -> StrResult<()> { + let mut world = SystemWorld::new(&command)?; + compile_once(&mut world, &mut command, false)?; + Ok(()) +} + +/// Compile a single time. +/// +/// Returns whether it compiled without errors. +#[tracing::instrument(skip_all)] +pub fn compile_once( + world: &mut SystemWorld, + command: &mut CompileCommand, + watching: bool, +) -> StrResult<()> { + tracing::info!("Starting compilation"); + + let start = std::time::Instant::now(); + if watching { + Status::Compiling.print(command).unwrap(); + } + + // Reset everything and ensure that the main file is still present. + world.reset(); + world.source(world.main()).map_err(|err| err.to_string())?; + + let result = typst::compile(world); + let duration = start.elapsed(); + + match result { + // Export the PDF / PNG. + Ok(document) => { + export(&document, command)?; + + tracing::info!("Compilation succeeded in {duration:?}"); + if watching { + Status::Success(duration).print(command).unwrap(); + } + + if let Some(open) = command.open.take() { + open_file(open.as_deref(), &command.output())?; + } + } + + // Print diagnostics. + Err(errors) => { + set_failed(); + tracing::info!("Compilation failed"); + + if watching { + Status::Error.print(command).unwrap(); + } + + print_diagnostics(world, *errors, command.diagnostic_format) + .map_err(|_| "failed to print diagnostics")?; + } + } + + Ok(()) +} + +/// Export into the target format. +fn export(document: &Document, command: &CompileCommand) -> StrResult<()> { + match command.output().extension() { + Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command), + _ => export_pdf(document, command), + } +} + +/// Export to a PDF. +fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { + let output = command.output(); + let buffer = typst::export::pdf(document); + fs::write(output, buffer).map_err(|_| "failed to write PDF file")?; + Ok(()) +} + +/// Export to one or multiple PNGs. +fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> { + // Determine whether we have a `{n}` numbering. + let output = command.output(); + let string = output.to_str().unwrap_or_default(); + let numbered = string.contains("{n}"); + if !numbered && document.pages.len() > 1 { + bail!("cannot export multiple PNGs without `{{n}}` in output path"); + } + + // Find a number width that accommodates all pages. For instance, the + // first page should be numbered "001" if there are between 100 and + // 999 pages. + let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize; + let mut storage; + + for (i, frame) in document.pages.iter().enumerate() { + let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE); + let path = if numbered { + storage = string.replace("{n}", &format!("{:0width$}", i + 1)); + Path::new(&storage) + } else { + output.as_path() + }; + pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; + } + + Ok(()) +} + +/// Opens the given file using: +/// - The default file viewer if `open` is `None`. +/// - The given viewer provided by `open` if it is `Some`. +fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> { + if let Some(app) = open { + open::with_in_background(path, app); + } else { + open::that_in_background(path); + } + + Ok(()) +} + +/// Print diagnostic messages to the terminal. +fn print_diagnostics( + world: &SystemWorld, + errors: Vec, + diagnostic_format: DiagnosticFormat, +) -> Result<(), codespan_reporting::files::Error> { + let mut w = match diagnostic_format { + DiagnosticFormat::Human => color_stream(), + DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never), + }; + + let mut config = term::Config { tab_width: 2, ..Default::default() }; + if diagnostic_format == DiagnosticFormat::Short { + config.display_style = term::DisplayStyle::Short; + } + + for error in errors { + // The main diagnostic. + let diag = Diagnostic::error() + .with_message(error.message) + .with_notes( + error + .hints + .iter() + .map(|e| (eco_format!("hint: {e}")).into()) + .collect(), + ) + .with_labels(vec![Label::primary(error.span.id(), error.span.range(world))]); + + term::emit(&mut w, &config, world, &diag)?; + + // Stacktrace-like helper diagnostics. + for point in error.trace { + let message = point.v.to_string(); + let help = Diagnostic::help().with_message(message).with_labels(vec![ + Label::primary(point.span.id(), point.span.range(world)), + ]); + + term::emit(&mut w, &config, world, &help)?; + } + } + + Ok(()) +} + +impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { + type FileId = FileId; + type Name = FileId; + type Source = Source; + + fn name(&'a self, id: FileId) -> CodespanResult { + Ok(id) + } + + fn source(&'a self, id: FileId) -> CodespanResult { + Ok(self.lookup(id)) + } + + fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult { + let source = self.lookup(id); + source + .byte_to_line(given) + .ok_or_else(|| CodespanError::IndexTooLarge { + given, + max: source.len_bytes(), + }) + } + + fn line_range( + &'a self, + id: FileId, + given: usize, + ) -> CodespanResult> { + let source = self.lookup(id); + source + .line_to_range(given) + .ok_or_else(|| CodespanError::LineTooLarge { given, max: source.len_lines() }) + } + + fn column_number( + &'a self, + id: FileId, + _: usize, + given: usize, + ) -> CodespanResult { + let source = self.lookup(id); + source.byte_to_column(given).ok_or_else(|| { + let max = source.len_bytes(); + if given <= max { + CodespanError::InvalidCharBoundary { given } + } else { + CodespanError::IndexTooLarge { given, max } + } + }) + } +} diff --git a/crates/typst-cli/src/fonts.rs b/crates/typst-cli/src/fonts.rs new file mode 100644 index 00000000..d8209fee --- /dev/null +++ b/crates/typst-cli/src/fonts.rs @@ -0,0 +1,183 @@ +use std::cell::OnceCell; +use std::env; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use memmap2::Mmap; +use typst::diag::StrResult; +use typst::font::{Font, FontBook, FontInfo, FontVariant}; +use typst::util::Bytes; +use walkdir::WalkDir; + +use crate::args::FontsCommand; + +/// Execute a font listing command. +pub fn fonts(command: FontsCommand) -> StrResult<()> { + let mut searcher = FontSearcher::new(); + searcher.search(&command.font_paths); + + for (name, infos) in searcher.book.families() { + println!("{name}"); + if command.variants { + for info in infos { + let FontVariant { style, weight, stretch } = info.variant; + println!("- Style: {style:?}, Weight: {weight:?}, Stretch: {stretch:?}"); + } + } + } + + Ok(()) +} + +/// Searches for fonts. +pub struct FontSearcher { + /// Metadata about all discovered fonts. + pub book: FontBook, + /// Slots that the fonts are loaded into. + pub fonts: Vec, +} + +/// Holds details about the location of a font and lazily the font itself. +pub struct FontSlot { + /// The path at which the font can be found on the system. + path: PathBuf, + /// The index of the font in its collection. Zero if the path does not point + /// to a collection. + index: u32, + /// The lazily loaded font. + font: OnceCell>, +} + +impl FontSlot { + /// Get the font for this slot. + pub fn get(&self) -> Option { + self.font + .get_or_init(|| { + let data = fs::read(&self.path).ok()?.into(); + Font::new(data, self.index) + }) + .clone() + } +} + +impl FontSearcher { + /// Create a new, empty system searcher. + pub fn new() -> Self { + Self { book: FontBook::new(), fonts: vec![] } + } + + /// Search everything that is available. + pub fn search(&mut self, font_paths: &[PathBuf]) { + self.search_system(); + + #[cfg(feature = "embed-fonts")] + self.add_embedded(); + + for path in font_paths { + self.search_dir(path) + } + } + + /// Add fonts that are embedded in the binary. + #[cfg(feature = "embed-fonts")] + fn add_embedded(&mut self) { + let mut process = |bytes: &'static [u8]| { + let buffer = Bytes::from_static(bytes); + for (i, font) in Font::iter(buffer).enumerate() { + self.book.push(font.info().clone()); + self.fonts.push(FontSlot { + path: PathBuf::new(), + index: i as u32, + font: OnceCell::from(Some(font)), + }); + } + }; + + macro_rules! add { + ($filename:literal) => { + process(include_bytes!(concat!("../../../assets/fonts/", $filename))); + }; + } + + // Embed default fonts. + add!("LinLibertine_R.ttf"); + add!("LinLibertine_RB.ttf"); + add!("LinLibertine_RBI.ttf"); + add!("LinLibertine_RI.ttf"); + add!("NewCMMath-Book.otf"); + add!("NewCMMath-Regular.otf"); + add!("NewCM10-Regular.otf"); + add!("NewCM10-Bold.otf"); + add!("NewCM10-Italic.otf"); + add!("NewCM10-BoldItalic.otf"); + add!("DejaVuSansMono.ttf"); + add!("DejaVuSansMono-Bold.ttf"); + add!("DejaVuSansMono-Oblique.ttf"); + add!("DejaVuSansMono-BoldOblique.ttf"); + } + + /// Search for fonts in the linux system font directories. + fn search_system(&mut self) { + if cfg!(target_os = "macos") { + self.search_dir("/Library/Fonts"); + self.search_dir("/Network/Library/Fonts"); + self.search_dir("/System/Library/Fonts"); + } else if cfg!(unix) { + self.search_dir("/usr/share/fonts"); + self.search_dir("/usr/local/share/fonts"); + } else if cfg!(windows) { + self.search_dir( + env::var_os("WINDIR") + .map(PathBuf::from) + .unwrap_or_else(|| "C:\\Windows".into()) + .join("Fonts"), + ); + + if let Some(roaming) = dirs::config_dir() { + self.search_dir(roaming.join("Microsoft\\Windows\\Fonts")); + } + + if let Some(local) = dirs::cache_dir() { + self.search_dir(local.join("Microsoft\\Windows\\Fonts")); + } + } + + if let Some(dir) = dirs::font_dir() { + self.search_dir(dir); + } + } + + /// Search for all fonts in a directory recursively. + fn search_dir(&mut self, path: impl AsRef) { + for entry in WalkDir::new(path) + .follow_links(true) + .sort_by(|a, b| a.file_name().cmp(b.file_name())) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if matches!( + path.extension().and_then(|s| s.to_str()), + Some("ttf" | "otf" | "TTF" | "OTF" | "ttc" | "otc" | "TTC" | "OTC"), + ) { + self.search_file(path); + } + } + } + + /// Index the fonts in the file at the given path. + fn search_file(&mut self, path: &Path) { + if let Ok(file) = File::open(path) { + if let Ok(mmap) = unsafe { Mmap::map(&file) } { + for (i, info) in FontInfo::iter(&mmap).enumerate() { + self.book.push(info); + self.fonts.push(FontSlot { + path: path.into(), + index: i as u32, + font: OnceCell::new(), + }); + } + } + } + } +} diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs new file mode 100644 index 00000000..425d05fd --- /dev/null +++ b/crates/typst-cli/src/main.rs @@ -0,0 +1,79 @@ +mod args; +mod compile; +mod fonts; +mod package; +mod tracing; +mod watch; +mod world; + +use std::cell::Cell; +use std::env; +use std::io::{self, IsTerminal, Write}; +use std::process::ExitCode; + +use clap::Parser; +use codespan_reporting::term::{self, termcolor}; +use termcolor::{ColorChoice, WriteColor}; + +use crate::args::{CliArguments, Command}; + +thread_local! { + /// The CLI's exit code. + static EXIT: Cell = Cell::new(ExitCode::SUCCESS); +} + +/// Entry point. +fn main() -> ExitCode { + let arguments = CliArguments::parse(); + let _guard = match crate::tracing::setup_tracing(&arguments) { + Ok(guard) => guard, + Err(err) => { + eprintln!("failed to initialize tracing {}", err); + None + } + }; + + let res = match arguments.command { + Command::Compile(command) => crate::compile::compile(command), + Command::Watch(command) => crate::watch::watch(command), + Command::Fonts(command) => crate::fonts::fonts(command), + }; + + if let Err(msg) = res { + set_failed(); + print_error(&msg).expect("failed to print error"); + } + + EXIT.with(|cell| cell.get()) +} + +/// Ensure a failure exit code. +fn set_failed() { + EXIT.with(|cell| cell.set(ExitCode::FAILURE)); +} + +/// Print an application-level error (independent from a source file). +fn print_error(msg: &str) -> io::Result<()> { + let mut w = color_stream(); + let styles = term::Styles::default(); + + w.set_color(&styles.header_error)?; + write!(w, "error")?; + + w.reset()?; + writeln!(w, ": {msg}.") +} + +/// Get stderr with color support if desirable. +fn color_stream() -> termcolor::StandardStream { + termcolor::StandardStream::stderr(if std::io::stderr().is_terminal() { + ColorChoice::Auto + } else { + ColorChoice::Never + }) +} + +/// Used by `args.rs`. +fn typst_version() -> &'static str { + env!("TYPST_VERSION") +} diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs new file mode 100644 index 00000000..6853796b --- /dev/null +++ b/crates/typst-cli/src/package.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use codespan_reporting::term::{self, termcolor}; +use termcolor::WriteColor; +use typst::diag::{PackageError, PackageResult}; +use typst::file::PackageSpec; + +use super::color_stream; + +/// Make a package available in the on-disk cache. +pub fn prepare_package(spec: &PackageSpec) -> PackageResult { + let subdir = + format!("typst/packages/{}/{}-{}", spec.namespace, spec.name, spec.version); + + if let Some(data_dir) = dirs::data_dir() { + let dir = data_dir.join(&subdir); + if dir.exists() { + return Ok(dir); + } + } + + if let Some(cache_dir) = dirs::cache_dir() { + let dir = cache_dir.join(&subdir); + + // Download from network if it doesn't exist yet. + if spec.namespace == "preview" && !dir.exists() { + download_package(spec, &dir)?; + } + + if dir.exists() { + return Ok(dir); + } + } + + Err(PackageError::NotFound(spec.clone())) +} + +/// Download a package over the network. +fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { + // The `@preview` namespace is the only namespace that supports on-demand + // fetching. + assert_eq!(spec.namespace, "preview"); + + let url = format!( + "https://packages.typst.org/preview/{}-{}.tar.gz", + spec.name, spec.version + ); + + print_downloading(spec).unwrap(); + let reader = match ureq::get(&url).call() { + Ok(response) => response.into_reader(), + Err(ureq::Error::Status(404, _)) => { + return Err(PackageError::NotFound(spec.clone())) + } + Err(_) => return Err(PackageError::NetworkFailed), + }; + + let decompressed = flate2::read::GzDecoder::new(reader); + tar::Archive::new(decompressed).unpack(package_dir).map_err(|_| { + fs::remove_dir_all(package_dir).ok(); + PackageError::MalformedArchive + }) +} + +/// Print that a package downloading is happening. +fn print_downloading(spec: &PackageSpec) -> io::Result<()> { + let mut w = color_stream(); + let styles = term::Styles::default(); + + w.set_color(&styles.header_help)?; + write!(w, "downloading")?; + + w.reset()?; + writeln!(w, " {spec}") +} diff --git a/crates/typst-cli/src/tracing.rs b/crates/typst-cli/src/tracing.rs new file mode 100644 index 00000000..80c2ff65 --- /dev/null +++ b/crates/typst-cli/src/tracing.rs @@ -0,0 +1,138 @@ +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Seek, SeekFrom}; +use std::path::PathBuf; + +use inferno::flamegraph::Options; +use tracing::metadata::LevelFilter; +use tracing_error::ErrorLayer; +use tracing_flame::{FlameLayer, FlushGuard}; +use tracing_subscriber::fmt; +use tracing_subscriber::prelude::*; + +use crate::args::{CliArguments, Command}; + +/// Initializes the tracing system and returns a guard that will flush the +/// flamegraph to disk when dropped. +pub fn setup_tracing(args: &CliArguments) -> io::Result> { + let flamegraph = match &args.command { + Command::Compile(command) => command.flamegraph.as_ref(), + Command::Watch(command) if command.flamegraph.is_some() => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot use --flamegraph with watch command", + )); + } + _ => None, + }; + + // Short circuit if we don't need to initialize flamegraph or debugging. + if flamegraph.is_none() && args.verbosity == 0 { + tracing_subscriber::fmt() + .without_time() + .with_max_level(level_filter(args)) + .init(); + + return Ok(None); + } + + // Build the FMT layer printing to the console. + let fmt_layer = fmt::Layer::default().without_time().with_filter(level_filter(args)); + + // Error layer for building backtraces + let error_layer = ErrorLayer::default(); + + // Build the registry. + let registry = tracing_subscriber::registry().with(fmt_layer).with(error_layer); + + let Some(path) = flamegraph else { + registry.init(); + return Ok(None); + }; + + // Create a temporary file to store the flamegraph data. + let temp_file = tempfile::tempfile()?; + let writer = BufWriter::new(temp_file.try_clone()?); + + // Build the flamegraph layer. + let flame_layer = FlameLayer::new(writer) + .with_empty_samples(false) + .with_threads_collapsed(true) + .with_module_path(false) + .with_file_and_line(true); + let flush_guard = flame_layer.flush_on_drop(); + + // Build the subscriber. + registry.with(flame_layer).init(); + + tracing::warn!( + "Flamegraph is enabled, this can create a large temporary \ + file and slow down the compilation process." + ); + + Ok(Some(TracingGuard { + flush_guard: Some(flush_guard), + temp_file, + output_svg: path.clone().unwrap_or_else(|| "flamegraph.svg".into()), + })) +} + +/// Returns the log level filter for the given verbosity level. +fn level_filter(args: &CliArguments) -> LevelFilter { + match args.verbosity { + 0 => LevelFilter::OFF, + 1 => LevelFilter::WARN, + 2 => LevelFilter::INFO, + 3 => LevelFilter::DEBUG, + _ => LevelFilter::TRACE, + } +} + +/// Will flush the flamegraph to disk when dropped. +struct TracingGuard { + flush_guard: Option>>, + temp_file: File, + output_svg: PathBuf, +} + +impl TracingGuard { + fn finish(&mut self) -> io::Result<()> { + if self.flush_guard.is_none() { + return Ok(()); + } + + tracing::info!("Flushing tracing flamegraph..."); + + // At this point, we're done tracing, so we can drop the guard. + // This will flush the tracing output to disk. + // We can then read the file and generate the flamegraph. + drop(self.flush_guard.take()); + + // Reset the file pointer to the beginning. + self.temp_file.seek(SeekFrom::Start(0))?; + + // Create the readers and writers. + let reader = BufReader::new(&mut self.temp_file); + let output = BufWriter::new(File::create(&self.output_svg)?); + + // Create the options: default in flame chart mode + let mut options = Options::default(); + options.flame_chart = true; + + inferno::flamegraph::from_reader(&mut options, reader, output) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + Ok(()) + } +} + +impl Drop for TracingGuard { + fn drop(&mut self) { + if !std::thread::panicking() { + if let Err(e) = self.finish() { + // Since we are finished, we cannot rely on tracing to log the + // error. + eprintln!("failed to flush tracing flamegraph: {e}"); + } + } + } +} diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs new file mode 100644 index 00000000..2ad73f85 --- /dev/null +++ b/crates/typst-cli/src/watch.rs @@ -0,0 +1,170 @@ +use std::collections::HashSet; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use codespan_reporting::term::{self, termcolor}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use same_file::is_same_file; +use termcolor::WriteColor; +use typst::diag::StrResult; +use typst::eval::eco_format; + +use crate::args::CompileCommand; +use crate::color_stream; +use crate::compile::compile_once; +use crate::world::SystemWorld; + +/// Execute a watching compilation command. +pub fn watch(mut command: CompileCommand) -> StrResult<()> { + // Create the world that serves sources, files, and fonts. + let mut world = SystemWorld::new(&command)?; + + // Perform initial compilation. + compile_once(&mut world, &mut command, true)?; + + // Setup file watching. + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()) + .map_err(|_| "failed to setup file watching")?; + + // Watch all the files that are used by the input file and its dependencies. + watch_dependencies(&mut world, &mut watcher, HashSet::new())?; + + // Handle events. + let timeout = std::time::Duration::from_millis(100); + let output = command.output(); + loop { + let mut recompile = false; + for event in rx + .recv() + .into_iter() + .chain(std::iter::from_fn(|| rx.recv_timeout(timeout).ok())) + { + let event = event.map_err(|_| "failed to watch directory")?; + recompile |= is_event_relevant(&event, &output); + } + + if recompile { + // Retrieve the dependencies of the last compilation. + let previous: HashSet = + world.dependencies().map(ToOwned::to_owned).collect(); + + // Recompile. + compile_once(&mut world, &mut command, true)?; + comemo::evict(10); + + // Adjust the watching. + watch_dependencies(&mut world, &mut watcher, previous)?; + } + } +} + +/// Adjust the file watching. Watches all new dependencies and unwatches +/// all `previous` dependencies that are not relevant anymore. +#[tracing::instrument(skip_all)] +fn watch_dependencies( + world: &mut SystemWorld, + watcher: &mut dyn Watcher, + mut previous: HashSet, +) -> StrResult<()> { + // Watch new paths that weren't watched yet. + for path in world.dependencies() { + let watched = previous.remove(path); + if path.exists() && !watched { + tracing::info!("Watching {}", path.display()); + watcher + .watch(path, RecursiveMode::NonRecursive) + .map_err(|_| eco_format!("failed to watch {path:?}"))?; + } + } + + // Unwatch old paths that don't need to be watched anymore. + for path in previous { + tracing::info!("Unwatching {}", path.display()); + watcher.unwatch(&path).ok(); + } + + Ok(()) +} + +/// Whether a watch event is relevant for compilation. +fn is_event_relevant(event: ¬ify::Event, output: &Path) -> bool { + // Never recompile because the output file changed. + if event + .paths + .iter() + .all(|path| is_same_file(path, output).unwrap_or(false)) + { + return false; + } + + match &event.kind { + notify::EventKind::Any => true, + notify::EventKind::Access(_) => false, + notify::EventKind::Create(_) => true, + notify::EventKind::Modify(kind) => match kind { + notify::event::ModifyKind::Any => true, + notify::event::ModifyKind::Data(_) => true, + notify::event::ModifyKind::Metadata(_) => false, + notify::event::ModifyKind::Name(_) => true, + notify::event::ModifyKind::Other => false, + }, + notify::EventKind::Remove(_) => true, + notify::EventKind::Other => false, + } +} + +/// The status in which the watcher can be. +pub enum Status { + Compiling, + Success(std::time::Duration), + Error, +} + +impl Status { + /// Clear the terminal and render the status message. + pub fn print(&self, command: &CompileCommand) -> io::Result<()> { + let output = command.output(); + let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); + let color = self.color(); + + let mut w = color_stream(); + if std::io::stderr().is_terminal() { + // Clear the terminal. + let esc = 27 as char; + write!(w, "{esc}c{esc}[1;1H")?; + } + + w.set_color(&color)?; + write!(w, "watching")?; + w.reset()?; + writeln!(w, " {}", command.input.display())?; + + w.set_color(&color)?; + write!(w, "writing to")?; + w.reset()?; + writeln!(w, " {}", output.display())?; + + writeln!(w)?; + writeln!(w, "[{timestamp}] {}", self.message())?; + writeln!(w)?; + + w.flush() + } + + fn message(&self) -> String { + match self { + Self::Compiling => "compiling ...".into(), + Self::Success(duration) => format!("compiled successfully in {duration:.2?}"), + Self::Error => "compiled with errors".into(), + } + } + + fn color(&self) -> termcolor::ColorSpec { + let styles = term::Styles::default(); + match self { + Self::Error => styles.header_error, + _ => styles.header_note, + } + } +} diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs new file mode 100644 index 00000000..f09a3f6c --- /dev/null +++ b/crates/typst-cli/src/world.rs @@ -0,0 +1,256 @@ +use std::cell::{OnceCell, RefCell, RefMut}; +use std::collections::HashMap; +use std::fs; +use std::hash::Hash; +use std::path::{Path, PathBuf}; + +use chrono::Datelike; +use comemo::Prehashed; +use same_file::Handle; +use siphasher::sip128::{Hasher128, SipHasher13}; +use typst::diag::{FileError, FileResult, StrResult}; +use typst::eval::{eco_format, Datetime, Library}; +use typst::file::FileId; +use typst::font::{Font, FontBook}; +use typst::syntax::Source; +use typst::util::{Bytes, PathExt}; +use typst::World; + +use crate::args::CompileCommand; +use crate::fonts::{FontSearcher, FontSlot}; +use crate::package::prepare_package; + +/// A world that provides access to the operating system. +pub struct SystemWorld { + /// The root relative to which absolute paths are resolved. + root: PathBuf, + /// The input path. + main: FileId, + /// Typst's standard library. + library: Prehashed, + /// Metadata about discovered fonts. + book: Prehashed, + /// Locations of and storage for lazily loaded fonts. + fonts: Vec, + /// Maps package-path combinations to canonical hashes. All package-path + /// combinations that point to the same file are mapped to the same hash. To + /// be used in conjunction with `paths`. + hashes: RefCell>>, + /// Maps canonical path hashes to source files and buffers. + paths: RefCell>, + /// The current date if requested. This is stored here to ensure it is + /// always the same within one compilation. Reset between compilations. + today: OnceCell>, +} + +impl SystemWorld { + /// Create a new system world. + pub fn new(command: &CompileCommand) -> StrResult { + let mut searcher = FontSearcher::new(); + searcher.search(&command.font_paths); + + // Resolve the system-global input path. + let system_input = command.input.canonicalize().map_err(|_| { + eco_format!("input file not found (searched at {})", command.input.display()) + })?; + + // Resolve the system-global root directory. + let root = { + let path = command + .root + .as_deref() + .or_else(|| system_input.parent()) + .unwrap_or(Path::new(".")); + path.canonicalize().map_err(|_| { + eco_format!("root directory not found (searched at {})", path.display()) + })? + }; + + // Resolve the input path within the project. + let project_input = system_input + .strip_prefix(&root) + .map(|path| Path::new("/").join(path)) + .map_err(|_| "input file must be contained in project root")?; + + Ok(Self { + root, + main: FileId::new(None, &project_input), + library: Prehashed::new(typst_library::build()), + book: Prehashed::new(searcher.book), + fonts: searcher.fonts, + hashes: RefCell::default(), + paths: RefCell::default(), + today: OnceCell::new(), + }) + } + + /// The id of the main source file. + pub fn main(&self) -> FileId { + self.main + } + + /// Return all paths the last compilation depended on. + pub fn dependencies(&mut self) -> impl Iterator { + self.paths.get_mut().values().map(|slot| slot.system_path.as_path()) + } + + /// Reset the compilation state in preparation of a new compilation. + pub fn reset(&mut self) { + self.hashes.borrow_mut().clear(); + self.paths.borrow_mut().clear(); + self.today.take(); + } + + /// Lookup a source file by id. + #[track_caller] + pub fn lookup(&self, id: FileId) -> Source { + self.source(id).expect("file id does not point to any source file") + } +} + +impl World for SystemWorld { + fn library(&self) -> &Prehashed { + &self.library + } + + fn book(&self) -> &Prehashed { + &self.book + } + + fn main(&self) -> Source { + self.source(self.main).unwrap() + } + + fn source(&self, id: FileId) -> FileResult { + self.slot(id)?.source() + } + + fn file(&self, id: FileId) -> FileResult { + self.slot(id)?.file() + } + + fn font(&self, index: usize) -> Option { + self.fonts[index].get() + } + + fn today(&self, offset: Option) -> Option { + *self.today.get_or_init(|| { + let naive = match offset { + None => chrono::Local::now().naive_local(), + Some(o) => (chrono::Utc::now() + chrono::Duration::hours(o)).naive_utc(), + }; + + Datetime::from_ymd( + naive.year(), + naive.month().try_into().ok()?, + naive.day().try_into().ok()?, + ) + }) + } +} + +impl SystemWorld { + /// Access the canonical slot for the given file id. + #[tracing::instrument(skip_all)] + fn slot(&self, id: FileId) -> FileResult> { + let mut system_path = PathBuf::new(); + let hash = self + .hashes + .borrow_mut() + .entry(id) + .or_insert_with(|| { + // Determine the root path relative to which the file path + // will be resolved. + let root = match id.package() { + Some(spec) => prepare_package(spec)?, + None => self.root.clone(), + }; + + // Join the path to the root. If it tries to escape, deny + // access. Note: It can still escape via symlinks. + system_path = + root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?; + + PathHash::new(&system_path) + }) + .clone()?; + + Ok(RefMut::map(self.paths.borrow_mut(), |paths| { + paths.entry(hash).or_insert_with(|| PathSlot { + id, + // This will only trigger if the `or_insert_with` above also + // triggered. + system_path, + source: OnceCell::new(), + buffer: OnceCell::new(), + }) + })) + } +} + +/// Holds canonical data for all paths pointing to the same entity. +/// +/// Both fields can be populated if the file is both imported and read(). +struct PathSlot { + /// The slot's canonical file id. + id: FileId, + /// The slot's path on the system. + system_path: PathBuf, + /// The lazily loaded source file for a path hash. + source: OnceCell>, + /// The lazily loaded buffer for a path hash. + buffer: OnceCell>, +} + +impl PathSlot { + fn source(&self) -> FileResult { + self.source + .get_or_init(|| { + let buf = read(&self.system_path)?; + let text = decode_utf8(buf)?; + Ok(Source::new(self.id, text)) + }) + .clone() + } + + fn file(&self) -> FileResult { + self.buffer + .get_or_init(|| read(&self.system_path).map(Bytes::from)) + .clone() + } +} + +/// A hash that is the same for all paths pointing to the same entity. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +struct PathHash(u128); + +impl PathHash { + fn new(path: &Path) -> FileResult { + let f = |e| FileError::from_io(e, path); + let handle = Handle::from_path(path).map_err(f)?; + let mut state = SipHasher13::new(); + handle.hash(&mut state); + Ok(Self(state.finish128().as_u128())) + } +} + +/// Read a file. +fn read(path: &Path) -> FileResult> { + let f = |e| FileError::from_io(e, path); + if fs::metadata(path).map_err(f)?.is_dir() { + Err(FileError::IsDirectory) + } else { + fs::read(path).map_err(f) + } +} + +/// Decode UTF-8 with an optional BOM. +fn decode_utf8(buf: Vec) -> FileResult { + Ok(if buf.starts_with(b"\xef\xbb\xbf") { + // Remove UTF-8 BOM. + std::str::from_utf8(&buf[3..])?.into() + } else { + // Assume UTF-8. + String::from_utf8(buf)? + }) +} diff --git a/crates/typst-docs/Cargo.toml b/crates/typst-docs/Cargo.toml new file mode 100644 index 00000000..47088815 --- /dev/null +++ b/crates/typst-docs/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "typst-docs" +version.workspace = true +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false + +[lib] +doctest = false +bench = false + +[dependencies] +typst = { path = "../typst" } +typst-library = { path = "../typst-library" } +comemo = "0.3" +heck = "0.4" +include_dir = "0.7" +once_cell = "1" +pulldown-cmark = "0.9" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.8" +syntect = { version = "5", default-features = false, features = ["parsing", "html", "regex-fancy"] } +typed-arena = "2" +unicode_names2 = "0.6.0" +unscanny = "0.1" +yaml-front-matter = "0.1" diff --git a/crates/typst-docs/src/contribs.rs b/crates/typst-docs/src/contribs.rs new file mode 100644 index 00000000..901b5f69 --- /dev/null +++ b/crates/typst-docs/src/contribs.rs @@ -0,0 +1,86 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::fmt::Write; + +use serde::Deserialize; + +use super::{Html, Resolver}; + +/// Build HTML detailing the contributors between two tags. +pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option { + let staff = ["laurmaedje", "reknih"]; + + // Determine number of contributions per person. + let mut contributors = HashMap::::new(); + for commit in resolver.commits(from, to) { + contributors + .entry(commit.author.login.clone()) + .or_insert_with(|| Contributor { + login: commit.author.login, + avatar: commit.author.avatar_url, + contributions: 0, + }) + .contributions += 1; + } + + // Keep only non-staff people. + let mut contributors: Vec<_> = contributors + .into_values() + .filter(|c| !staff.contains(&c.login.as_str())) + .collect(); + + // Sort by highest number of commits. + contributors.sort_by_key(|c| (Reverse(c.contributions), c.login.clone())); + if contributors.is_empty() { + return None; + } + + let mut html = "Thanks to everyone who contributed to this release!".to_string(); + html += "
    "; + + for Contributor { login, avatar, contributions } in contributors { + let login = login.replace('\"', """).replace('&', "&"); + let avatar = avatar.replace("?v=", "?s=64&v="); + let s = if contributions > 1 { "s" } else { "" }; + write!( + html, + r#"
  • + + GitHub avatar of {login} + +
  • "# + ) + .unwrap(); + } + + html += "
"; + + Some(Html::new(html)) +} + +#[derive(Debug)] +struct Contributor { + login: String, + avatar: String, + contributions: usize, +} + +/// A commit on the `typst` repository. +#[derive(Debug, Deserialize)] +pub struct Commit { + author: Author, +} + +/// A commit author. +#[derive(Debug, Deserialize)] +pub struct Author { + login: String, + avatar_url: String, +} diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs new file mode 100644 index 00000000..0d40f46b --- /dev/null +++ b/crates/typst-docs/src/html.rs @@ -0,0 +1,513 @@ +use std::ops::Range; + +use comemo::Prehashed; +use pulldown_cmark as md; +use typed_arena::Arena; +use typst::diag::FileResult; +use typst::eval::Datetime; +use typst::file::FileId; +use typst::font::{Font, FontBook}; +use typst::geom::{Point, Size}; +use typst::syntax::Source; +use typst::util::Bytes; +use typst::World; +use yaml_front_matter::YamlFrontMatter; + +use super::*; + +/// HTML documentation. +#[derive(Serialize)] +#[serde(transparent)] +pub struct Html { + raw: String, + #[serde(skip)] + md: String, + #[serde(skip)] + description: Option, + #[serde(skip)] + outline: Vec, +} + +impl Html { + /// Create HTML from a raw string. + pub fn new(raw: String) -> Self { + Self { + md: String::new(), + raw, + description: None, + outline: vec![], + } + } + + /// Convert markdown to HTML. + #[track_caller] + pub fn markdown(resolver: &dyn Resolver, md: &str) -> Self { + Self::markdown_with_id_base(resolver, md, "") + } + + /// Convert markdown to HTML, preceding all fragment identifiers with the + /// `id_base`. + #[track_caller] + pub fn markdown_with_id_base( + resolver: &dyn Resolver, + md: &str, + id_base: &str, + ) -> Self { + let mut text = md; + let mut description = None; + let document = YamlFrontMatter::parse::(md); + if let Ok(document) = &document { + text = &document.content; + description = Some(document.metadata.description.clone()) + } + + let options = md::Options::ENABLE_TABLES | md::Options::ENABLE_HEADING_ATTRIBUTES; + + let ids = Arena::new(); + let mut handler = Handler::new(resolver, id_base.into(), &ids); + let iter = md::Parser::new_ext(text, options) + .filter_map(|mut event| handler.handle(&mut event).then_some(event)); + + let mut raw = String::new(); + md::html::push_html(&mut raw, iter); + raw.truncate(raw.trim_end().len()); + + Html { + md: text.into(), + raw, + description, + outline: handler.outline, + } + } + + /// The raw HTML. + pub fn as_str(&self) -> &str { + &self.raw + } + + /// The original Markdown, if any. + pub fn md(&self) -> &str { + &self.md + } + + /// The title of the HTML. + /// + /// Returns `None` if the HTML doesn't start with an `h1` tag. + pub fn title(&self) -> Option<&str> { + let mut s = Scanner::new(&self.raw); + s.eat_if("

").then(|| s.eat_until("

")) + } + + /// The outline of the HTML. + pub fn outline(&self) -> Vec { + self.outline.clone() + } + + /// The description from the front matter. + pub fn description(&self) -> Option { + self.description.clone() + } +} + +impl Debug for Html { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Html({:?})", self.title().unwrap_or("..")) + } +} + +/// Front matter metadata. +#[derive(Deserialize)] +struct Metadata { + description: String, +} + +struct Handler<'a> { + resolver: &'a dyn Resolver, + lang: Option, + code: String, + outline: Vec, + id_base: String, + ids: &'a Arena, +} + +impl<'a> Handler<'a> { + fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena) -> Self { + Self { + resolver, + lang: None, + code: String::new(), + outline: vec![], + id_base, + ids, + } + } + + fn handle(&mut self, event: &mut md::Event<'a>) -> bool { + match event { + // Rewrite Markdown images. + md::Event::Start(md::Tag::Image(_, path, _)) => { + *path = self.handle_image(path).into(); + } + + // Rewrite HTML images. + md::Event::Html(html) if html.starts_with(" { + let range = html_attr_range(html, "src").unwrap(); + let path = &html[range.clone()]; + let mut buf = html.to_string(); + buf.replace_range(range, &self.handle_image(path)); + *html = buf.into(); + } + + // Register HTML headings for the outline. + md::Event::Start(md::Tag::Heading(level, Some(id), _)) => { + self.handle_heading(id, level); + } + + // Also handle heading closings. + md::Event::End(md::Tag::Heading(level, Some(_), _)) => { + if *level > md::HeadingLevel::H1 && !self.id_base.is_empty() { + nest_heading(level); + } + } + + // Rewrite contributor sections. + md::Event::Html(html) if html.starts_with(" { + let from = html_attr(html, "from").unwrap(); + let to = html_attr(html, "to").unwrap(); + let Some(output) = contributors(self.resolver, from, to) else { return false }; + *html = output.raw.into(); + } + + // Rewrite links. + md::Event::Start(md::Tag::Link(ty, dest, _)) => { + assert!( + matches!(ty, md::LinkType::Inline | md::LinkType::Reference), + "unsupported link type: {ty:?}", + ); + + *dest = self + .handle_link(dest) + .unwrap_or_else(|| panic!("invalid link: {dest}")) + .into(); + } + + // Inline raw. + md::Event::Code(code) => { + let mut chars = code.chars(); + let parser = match (chars.next(), chars.next_back()) { + (Some('['), Some(']')) => typst::syntax::parse, + (Some('{'), Some('}')) => typst::syntax::parse_code, + _ => return true, + }; + + let root = parser(&code[1..code.len() - 1]); + let html = typst::ide::highlight_html(&root); + *event = md::Event::Html(html.into()); + } + + // Code blocks. + md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => { + self.lang = Some(lang.as_ref().into()); + self.code = String::new(); + return false; + } + md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => { + let Some(lang) = self.lang.take() else { return false }; + let html = code_block(self.resolver, &lang, &self.code); + *event = md::Event::Html(html.raw.into()); + } + + // Example with preview. + md::Event::Text(text) => { + if self.lang.is_some() { + self.code.push_str(text); + return false; + } + } + + _ => {} + } + + true + } + + fn handle_image(&self, link: &str) -> String { + if let Some(file) = FILES.get_file(link) { + self.resolver.image(link, file.contents()) + } else if let Some(url) = self.resolver.link(link) { + url + } else { + panic!("missing image: {link}") + } + } + + fn handle_heading(&mut self, id: &mut &'a str, level: &mut md::HeadingLevel) { + if *level == md::HeadingLevel::H1 { + return; + } + + // Special case for things like "v0.3.0". + let name = if id.starts_with('v') && id.contains('.') { + id.to_string() + } else { + id.to_title_case() + }; + + let mut children = &mut self.outline; + let mut depth = *level as usize; + while depth > 2 { + if !children.is_empty() { + children = &mut children.last_mut().unwrap().children; + } + depth -= 1; + } + + // Put base before id. + if !self.id_base.is_empty() { + nest_heading(level); + *id = self.ids.alloc(format!("{}-{id}", self.id_base)).as_str(); + } + + children.push(OutlineItem { id: id.to_string(), name, children: vec![] }); + } + + fn handle_link(&self, link: &str) -> Option { + if link.starts_with('#') || link.starts_with("http") { + return Some(link.into()); + } + + if !link.starts_with('$') { + return self.resolver.link(link); + } + + let root = link.split('/').next()?; + let rest = &link[root.len()..].trim_matches('/'); + let base = match root { + "$tutorial" => "/docs/tutorial/", + "$reference" => "/docs/reference/", + "$category" => "/docs/reference/", + "$syntax" => "/docs/reference/syntax/", + "$styling" => "/docs/reference/styling/", + "$scripting" => "/docs/reference/scripting/", + "$types" => "/docs/reference/types/", + "$type" => "/docs/reference/types/", + "$func" => "/docs/reference/", + "$guides" => "/docs/guides/", + "$packages" => "/docs/packages/", + "$changelog" => "/docs/changelog/", + "$community" => "/docs/community/", + _ => panic!("unknown link root: {root}"), + }; + + let mut route = base.to_string(); + if root == "$type" && rest.contains('.') { + let mut parts = rest.split('.'); + let ty = parts.next()?; + let method = parts.next()?; + route.push_str(ty); + route.push_str("/#methods-"); + route.push_str(method); + } else if root == "$func" { + let mut parts = rest.split('.').peekable(); + let first = parts.peek().copied(); + let mut focus = &LIBRARY.global; + while let Some(m) = first.and_then(|name| module(focus, name).ok()) { + focus = m; + parts.next(); + } + + let name = parts.next()?; + + let value = focus.get(name).ok()?; + let Value::Func(func) = value else { return None }; + let info = func.info()?; + route.push_str(info.category); + route.push('/'); + + if let Some(group) = GROUPS + .iter() + .filter(|_| first == Some("math")) + .find(|group| group.functions.iter().any(|func| func == info.name)) + { + route.push_str(&group.name); + route.push_str("/#"); + route.push_str(info.name); + if let Some(param) = parts.next() { + route.push_str("-parameters-"); + route.push_str(param); + } + } else { + route.push_str(name); + route.push('/'); + if let Some(next) = parts.next() { + if info.params.iter().any(|param| param.name == next) { + route.push_str("#parameters-"); + route.push_str(next); + } else if info.scope.iter().any(|(name, _)| name == next) { + route.push('#'); + route.push_str(info.name); + route.push('-'); + route.push_str(next); + } else { + return None; + } + } + } + } else { + route.push_str(rest); + } + + if !route.contains('#') && !route.ends_with('/') { + route.push('/'); + } + + Some(route) + } +} + +/// Render a code block to HTML. +fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { + let mut display = String::new(); + let mut compile = String::new(); + for line in text.lines() { + if let Some(suffix) = line.strip_prefix(">>>") { + compile.push_str(suffix); + compile.push('\n'); + } else if let Some(suffix) = line.strip_prefix("<<< ") { + display.push_str(suffix); + display.push('\n'); + } else { + display.push_str(line); + display.push('\n'); + compile.push_str(line); + compile.push('\n'); + } + } + + let mut parts = lang.split(':'); + let lang = parts.next().unwrap_or(lang); + + let mut zoom: Option<[Abs; 4]> = None; + let mut single = false; + if let Some(args) = parts.next() { + single = true; + if !args.contains("single") { + zoom = args + .split(',') + .take(4) + .map(|s| Abs::pt(s.parse().unwrap())) + .collect::>() + .try_into() + .ok(); + } + } + + if lang.is_empty() { + let mut buf = String::from("
");
+        md::escape::escape_html(&mut buf, &display).unwrap();
+        buf.push_str("
"); + return Html::new(buf); + } else if !matches!(lang, "example" | "typ") { + let set = &*typst_library::text::SYNTAXES; + let buf = syntect::html::highlighted_html_for_string( + &display, + set, + set.find_syntax_by_token(lang) + .unwrap_or_else(|| panic!("unsupported highlighting language: {lang}")), + &typst_library::text::THEME, + ) + .expect("failed to highlight code"); + return Html::new(buf); + } + + let root = typst::syntax::parse(&display); + let highlighted = Html::new(typst::ide::highlight_html(&root)); + if lang == "typ" { + return Html::new(format!("
{}
", highlighted.as_str())); + } + + let id = FileId::new(None, Path::new("/main.typ")); + let source = Source::new(id, compile); + let world = DocWorld(source); + let mut frames = match typst::compile(&world) { + Ok(doc) => doc.pages, + Err(err) => { + let msg = &err[0].message; + panic!("while trying to compile:\n{text}:\n\nerror: {msg}"); + } + }; + + if let Some([x, y, w, h]) = zoom { + frames[0].translate(Point::new(-x, -y)); + *frames[0].size_mut() = Size::new(w, h); + } + + if single { + frames.truncate(1); + } + + resolver.example(highlighted, &frames) +} + +/// Extract an attribute value from an HTML element. +fn html_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> { + html.get(html_attr_range(html, attr)?) +} + +/// Extract the range of the attribute value of an HTML element. +fn html_attr_range(html: &str, attr: &str) -> Option> { + let needle = format!("{attr}=\""); + let offset = html.find(&needle)? + needle.len(); + let len = html[offset..].find('"')?; + Some(offset..offset + len) +} + +/// Increase the nesting level of a Markdown heading. +fn nest_heading(level: &mut md::HeadingLevel) { + *level = match &level { + md::HeadingLevel::H1 => md::HeadingLevel::H2, + md::HeadingLevel::H2 => md::HeadingLevel::H3, + md::HeadingLevel::H3 => md::HeadingLevel::H4, + md::HeadingLevel::H4 => md::HeadingLevel::H5, + md::HeadingLevel::H5 => md::HeadingLevel::H6, + v => **v, + }; +} + +/// A world for example compilations. +struct DocWorld(Source); + +impl World for DocWorld { + fn library(&self) -> &Prehashed { + &LIBRARY + } + + fn book(&self) -> &Prehashed { + &FONTS.0 + } + + fn main(&self) -> Source { + self.0.clone() + } + + fn source(&self, _: FileId) -> FileResult { + Ok(self.0.clone()) + } + + fn file(&self, id: FileId) -> FileResult { + assert!(id.package().is_none()); + Ok(FILES + .get_file(id.path().strip_prefix("/").unwrap()) + .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display())) + .contents() + .into()) + } + + fn font(&self, index: usize) -> Option { + Some(FONTS.1[index].clone()) + } + + fn today(&self, _: Option) -> Option { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } +} diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs new file mode 100644 index 00000000..d7f816d2 --- /dev/null +++ b/crates/typst-docs/src/lib.rs @@ -0,0 +1,1002 @@ +//! Documentation provider for Typst. + +mod contribs; +mod html; + +pub use contribs::{contributors, Author, Commit}; +pub use html::Html; + +use std::fmt::{self, Debug, Formatter}; +use std::path::Path; + +use comemo::Prehashed; +use heck::ToTitleCase; +use include_dir::{include_dir, Dir}; +use once_cell::sync::Lazy; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_yaml as yaml; +use typst::doc::Frame; +use typst::eval::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, Value}; +use typst::font::{Font, FontBook}; +use typst::geom::{Abs, Smart}; +use typst_library::layout::{Margin, PageElem}; +use unscanny::Scanner; + +static DOCS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../docs"); +static FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/files"); +static DETAILS: Lazy = Lazy::new(|| yaml("reference/details.yml")); +static GROUPS: Lazy> = Lazy::new(|| yaml("reference/groups.yml")); + +static FONTS: Lazy<(Prehashed, Vec)> = Lazy::new(|| { + static DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/fonts"); + let fonts: Vec<_> = DIR + .files() + .flat_map(|file| Font::iter(file.contents().into())) + .collect(); + let book = FontBook::from_fonts(&fonts); + (Prehashed::new(book), fonts) +}); + +static LIBRARY: Lazy> = Lazy::new(|| { + let mut lib = typst_library::build(); + lib.styles + .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into()))); + lib.styles.set(PageElem::set_height(Smart::Auto)); + lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom( + Abs::pt(15.0).into(), + ))))); + typst::eval::set_lang_items(lib.items.clone()); + Prehashed::new(lib) +}); + +/// Build documentation pages. +pub fn provide(resolver: &dyn Resolver) -> Vec { + vec![ + markdown_page(resolver, "/docs/", "overview.md").with_route("/docs/"), + tutorial_pages(resolver), + reference_pages(resolver), + guides_pages(resolver), + packages_page(resolver), + markdown_page(resolver, "/docs/", "changelog.md"), + markdown_page(resolver, "/docs/", "community.md"), + ] +} + +/// Resolve consumer dependencies. +pub trait Resolver { + /// Try to resolve a link that the system cannot resolve itself. + fn link(&self, link: &str) -> Option; + + /// Produce an URL for an image file. + fn image(&self, filename: &str, data: &[u8]) -> String; + + /// Produce HTML for an example. + fn example(&self, source: Html, frames: &[Frame]) -> Html; + + /// Determine the commits between two tags. + fn commits(&self, from: &str, to: &str) -> Vec; +} + +/// Details about a documentation page and its children. +#[derive(Debug, Serialize)] +pub struct PageModel { + pub route: String, + pub title: String, + pub description: String, + pub part: Option<&'static str>, + pub outline: Vec, + pub body: BodyModel, + pub children: Vec, +} + +impl PageModel { + fn with_route(self, route: &str) -> Self { + Self { route: route.into(), ..self } + } + + fn with_part(self, part: &'static str) -> Self { + Self { part: Some(part), ..self } + } +} + +/// An element in the "On This Page" outline. +#[derive(Debug, Clone, Serialize)] +pub struct OutlineItem { + id: String, + name: String, + children: Vec, +} + +/// Details about the body of a documentation page. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "kind", content = "content")] +pub enum BodyModel { + Html(Html), + Category(CategoryModel), + Func(FuncModel), + Funcs(FuncsModel), + Type(TypeModel), + Symbols(SymbolsModel), + Packages(Html), +} + +/// Build the tutorial. +fn tutorial_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "tutorial/welcome.md"); + page.children = DOCS + .get_dir("tutorial") + .unwrap() + .files() + .filter(|file| file.path() != Path::new("tutorial/welcome.md")) + .map(|file| markdown_page(resolver, "/docs/tutorial/", file.path())) + .collect(); + page +} + +/// Build the reference. +fn reference_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "reference/welcome.md"); + page.children = vec![ + markdown_page(resolver, "/docs/reference/", "reference/syntax.md") + .with_part("Language"), + markdown_page(resolver, "/docs/reference/", "reference/styling.md"), + markdown_page(resolver, "/docs/reference/", "reference/scripting.md"), + types_page(resolver, "/docs/reference/"), + category_page(resolver, "text").with_part("Content"), + category_page(resolver, "math"), + category_page(resolver, "layout"), + category_page(resolver, "visualize"), + category_page(resolver, "meta"), + category_page(resolver, "symbols"), + category_page(resolver, "foundations").with_part("Compute"), + category_page(resolver, "calculate"), + category_page(resolver, "construct"), + category_page(resolver, "data-loading"), + ]; + page +} + +/// Build the guides section. +fn guides_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "guides/welcome.md"); + page.children = + vec![markdown_page(resolver, "/docs/guides/", "guides/guide-for-latex-users.md")]; + page +} + +/// Build the packages section. +fn packages_page(resolver: &dyn Resolver) -> PageModel { + PageModel { + route: "/docs/packages/".into(), + title: "Packages".into(), + description: "Packages for Typst.".into(), + part: None, + outline: vec![], + body: BodyModel::Packages(Html::markdown(resolver, details("packages"))), + children: vec![], + } +} + +/// Create a page from a markdown file. +#[track_caller] +fn markdown_page( + resolver: &dyn Resolver, + parent: &str, + path: impl AsRef, +) -> PageModel { + assert!(parent.starts_with('/') && parent.ends_with('/')); + let md = DOCS.get_file(path).unwrap().contents_utf8().unwrap(); + let html = Html::markdown(resolver, md); + let title = html.title().expect("chapter lacks a title").to_string(); + PageModel { + route: format!("{parent}{}/", urlify(&title)), + title, + description: html.description().unwrap(), + part: None, + outline: html.outline(), + body: BodyModel::Html(html), + children: vec![], + } +} + +/// Details about a category. +#[derive(Debug, Serialize)] +pub struct CategoryModel { + pub name: String, + pub details: Html, + pub kind: &'static str, + pub items: Vec, +} + +/// Details about a category item. +#[derive(Debug, Serialize)] +pub struct CategoryItem { + pub name: String, + pub route: String, + pub oneliner: String, + pub code: bool, +} + +/// Create a page for a category. +#[track_caller] +fn category_page(resolver: &dyn Resolver, category: &str) -> PageModel { + let route = format!("/docs/reference/{category}/"); + let mut children = vec![]; + let mut items = vec![]; + + let focus = match category { + "math" => &LIBRARY.math, + "calculate" => module(&LIBRARY.global, "calc").unwrap(), + _ => &LIBRARY.global, + }; + + let parents: &[&str] = match category { + "math" => &[], + "calculate" => &["calc"], + _ => &[], + }; + + let grouped = match category { + "math" => GROUPS.as_slice(), + _ => &[], + }; + + // Add functions. + for (_, value) in focus.scope().iter() { + let Value::Func(func) = value else { continue }; + let Some(info) = func.info() else { continue }; + if info.category != category { + continue; + } + + // Skip grouped functions. + if grouped + .iter() + .flat_map(|group| &group.functions) + .any(|f| f == info.name) + { + continue; + } + + let subpage = function_page(resolver, &route, func, info, parents); + items.push(CategoryItem { + name: info.name.into(), + route: subpage.route.clone(), + oneliner: oneliner(info.docs).into(), + code: true, + }); + children.push(subpage); + } + + // Add grouped functions. + for group in grouped { + let mut functions = vec![]; + let mut outline = vec![OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }]; + + for name in &group.functions { + let value = focus.get(name).unwrap(); + let Value::Func(func) = value else { panic!("not a function") }; + let info = func.info().unwrap(); + let func = func_model(resolver, func, info, &[], info.name); + let id = urlify(&func.path.join("-")); + let children = func_outline(&func, &id, false); + outline.push(OutlineItem { id, name: func.display.into(), children }); + functions.push(func); + } + + let route = format!("{}{}/", route, group.name); + items.push(CategoryItem { + name: group.name.clone(), + route: route.clone(), + oneliner: oneliner(&group.description).into(), + code: false, + }); + + children.push(PageModel { + route, + title: group.display.clone(), + description: format!("Documentation for {} group of functions.", group.name), + part: None, + outline, + body: BodyModel::Funcs(FuncsModel { + name: group.name.clone(), + display: group.display.clone(), + details: Html::markdown(resolver, &group.description), + functions, + }), + children: vec![], + }); + } + + children.sort_by_cached_key(|child| child.title.clone()); + items.sort_by_cached_key(|item| item.name.clone()); + + // Add symbol pages. These are ordered manually. + if category == "symbols" { + for module in ["sym", "emoji"] { + let subpage = symbol_page(resolver, &route, module); + items.push(CategoryItem { + name: module.into(), + route: subpage.route.clone(), + oneliner: oneliner(details(module)).into(), + code: true, + }); + children.push(subpage); + } + } + + let name = category.to_title_case(); + let kind = match category { + "symbols" => "Modules", + _ => "Functions", + }; + + PageModel { + route, + title: name.clone(), + description: format!("Documentation for functions related to {name} in Typst."), + part: None, + outline: category_outline(kind), + body: BodyModel::Category(CategoryModel { + name, + details: Html::markdown(resolver, details(category)), + kind, + items, + }), + children, + } +} + +/// Produce an outline for a category page. +fn category_outline(kind: &str) -> Vec { + vec![ + OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }, + OutlineItem { + id: urlify(kind), + name: kind.into(), + children: vec![], + }, + ] +} + +/// Details about a function. +#[derive(Debug, Serialize)] +pub struct FuncModel { + pub path: Vec<&'static str>, + pub display: &'static str, + pub keywords: Option<&'static str>, + pub oneliner: &'static str, + pub element: bool, + pub details: Html, + pub params: Vec, + pub returns: Vec<&'static str>, + pub methods: Vec, + pub scope: Vec, +} + +/// Details about a group of functions. +#[derive(Debug, Serialize)] +pub struct FuncsModel { + pub name: String, + pub display: String, + pub details: Html, + pub functions: Vec, +} + +/// Create a page for a function. +fn function_page( + resolver: &dyn Resolver, + parent: &str, + func: &Func, + info: &FuncInfo, + parents: &[&'static str], +) -> PageModel { + let model = func_model(resolver, func, info, parents, ""); + PageModel { + route: format!("{parent}{}/", urlify(info.name)), + title: info.display.to_string(), + description: format!("Documentation for the `{}` function.", info.name), + part: None, + outline: func_outline(&model, "", true), + body: BodyModel::Func(model), + children: vec![], + } +} + +/// Produce a function's model. +fn func_model( + resolver: &dyn Resolver, + func: &Func, + info: &FuncInfo, + parents: &[&'static str], + id_base: &str, +) -> FuncModel { + let mut s = unscanny::Scanner::new(info.docs); + let docs = s.eat_until("\n## Methods").trim(); + + let mut path = parents.to_vec(); + let mut name = info.name; + for parent in parents.iter().rev() { + name = name + .strip_prefix(parent) + .or(name.strip_prefix(parent.strip_suffix('s').unwrap_or(parent))) + .unwrap_or(name) + .trim_matches('-'); + } + path.push(name); + + let scope = info + .scope + .iter() + .filter_map(|(_, value)| { + let Value::Func(func) = value else { return None }; + let info = func.info().unwrap(); + Some(func_model(resolver, func, info, &path, id_base)) + }) + .collect(); + + let mut returns = vec![]; + casts(resolver, &mut returns, &mut vec![], &info.returns); + returns.sort_by_key(|ty| type_index(ty)); + if returns == ["none"] { + returns.clear(); + } + + FuncModel { + path, + display: info.display, + keywords: info.keywords, + oneliner: oneliner(docs), + element: func.element().is_some(), + details: Html::markdown_with_id_base(resolver, docs, id_base), + params: info.params.iter().map(|param| param_model(resolver, param)).collect(), + returns, + methods: method_models(resolver, info.docs), + scope, + } +} + +/// Produce an outline for a function page. +fn func_outline(model: &FuncModel, base: &str, summary: bool) -> Vec { + let mut outline = vec![]; + + if summary { + outline.push(OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }); + } + + outline.extend(model.details.outline()); + + if !model.params.is_empty() { + let join = if base.is_empty() { "" } else { "-" }; + outline.push(OutlineItem { + id: format!("{base}{join}parameters"), + name: "Parameters".into(), + children: model + .params + .iter() + .map(|param| OutlineItem { + id: format!("{base}{join}parameters-{}", urlify(param.name)), + name: param.name.into(), + children: vec![], + }) + .collect(), + }); + } + + for func in &model.scope { + let id = urlify(&func.path.join("-")); + let children = func_outline(func, &id, false); + outline.push(OutlineItem { id, name: func.display.into(), children }) + } + + outline.extend(methods_outline(&model.methods)); + outline +} + +/// Details about a function parameter. +#[derive(Debug, Serialize)] +pub struct ParamModel { + pub name: &'static str, + pub details: Html, + pub example: Option, + pub types: Vec<&'static str>, + pub strings: Vec, + pub default: Option, + pub positional: bool, + pub named: bool, + pub required: bool, + pub variadic: bool, + pub settable: bool, +} + +/// A specific string that can be passed as an argument. +#[derive(Debug, Serialize)] +pub struct StrParam { + pub string: String, + pub details: Html, +} + +/// Produce a parameter's model. +fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { + let mut types = vec![]; + let mut strings = vec![]; + casts(resolver, &mut types, &mut strings, &info.cast); + if !strings.is_empty() && !types.contains(&"string") { + types.push("string"); + } + types.sort_by_key(|ty| type_index(ty)); + + let mut details = info.docs; + let mut example = None; + if let Some(mut i) = info.docs.find("```example") { + while info.docs[..i].ends_with('`') { + i -= 1; + } + details = &info.docs[..i]; + example = Some(&info.docs[i..]); + } + + ParamModel { + name: info.name, + details: Html::markdown(resolver, details), + example: example.map(|md| Html::markdown(resolver, md)), + types, + strings, + default: info.default.map(|default| { + let node = typst::syntax::parse_code(&default().repr()); + Html::new(typst::ide::highlight_html(&node)) + }), + positional: info.positional, + named: info.named, + required: info.required, + variadic: info.variadic, + settable: info.settable, + } +} + +/// Process cast information into types and strings. +fn casts( + resolver: &dyn Resolver, + types: &mut Vec<&'static str>, + strings: &mut Vec, + info: &CastInfo, +) { + match info { + CastInfo::Any => types.push("any"), + CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam { + string: string.to_string(), + details: Html::markdown(resolver, docs), + }), + CastInfo::Value(..) => {} + CastInfo::Type(ty) => types.push(ty), + CastInfo::Union(options) => { + for option in options { + casts(resolver, types, strings, option); + } + } + } +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct TypeModel { + pub name: String, + pub oneliner: &'static str, + pub details: Html, + pub methods: Vec, +} + +/// Details about a built-in method on a type. +#[derive(Debug, Serialize)] +pub struct MethodModel { + pub name: &'static str, + pub details: Html, + pub params: Vec, + pub returns: Vec<&'static str>, +} + +/// Create a page for the types. +fn types_page(resolver: &dyn Resolver, parent: &str) -> PageModel { + let route = format!("{parent}types/"); + let mut children = vec![]; + let mut items = vec![]; + + for model in type_models(resolver) { + let route = format!("{route}{}/", urlify(&model.name)); + items.push(CategoryItem { + name: model.name.clone(), + route: route.clone(), + oneliner: model.oneliner.into(), + code: true, + }); + children.push(PageModel { + route, + title: model.name.to_title_case(), + description: format!("Documentation for the `{}` type.", model.name), + part: None, + outline: type_outline(&model), + body: BodyModel::Type(model), + children: vec![], + }); + } + + PageModel { + route, + title: "Types".into(), + description: "Documentation for Typst's built-in types.".into(), + part: None, + outline: category_outline("Types"), + body: BodyModel::Category(CategoryModel { + name: "Types".into(), + details: Html::markdown(resolver, details("types")), + kind: "Types", + items, + }), + children, + } +} + +/// Produce the types' models. +fn type_models(resolver: &dyn Resolver) -> Vec { + let file = DOCS.get_file("reference/types.md").unwrap(); + let text = file.contents_utf8().unwrap(); + + let mut s = unscanny::Scanner::new(text); + let mut types = vec![]; + + while s.eat_if("# ") { + let part = s.eat_until("\n# "); + types.push(type_model(resolver, part)); + s.eat_if('\n'); + } + + types +} + +/// Produce a type's model. +fn type_model(resolver: &dyn Resolver, part: &'static str) -> TypeModel { + let mut s = unscanny::Scanner::new(part); + let display = s.eat_until('\n').trim(); + let docs = s.eat_until("\n## Methods").trim(); + TypeModel { + name: display.to_lowercase(), + oneliner: oneliner(docs), + details: Html::markdown(resolver, docs), + methods: method_models(resolver, part), + } +} + +/// Produce multiple methods' models. +fn method_models(resolver: &dyn Resolver, docs: &'static str) -> Vec { + let mut s = unscanny::Scanner::new(docs); + s.eat_until("\n## Methods"); + s.eat_whitespace(); + + let mut methods = vec![]; + if s.eat_if("## Methods") { + s.eat_until("\n### "); + while s.eat_if("\n### ") { + methods.push(method_model(resolver, s.eat_until("\n### "))); + } + } + + methods +} + +/// Produce a method's model. +fn method_model(resolver: &dyn Resolver, part: &'static str) -> MethodModel { + let mut s = unscanny::Scanner::new(part); + let mut params = vec![]; + let mut returns = vec![]; + + let name = s.eat_until('(').trim(); + s.expect("()"); + let docs = s.eat_until("\n- ").trim(); + + while s.eat_if("\n- ") { + let name = s.eat_until(':'); + s.expect(": "); + let types: Vec<_> = + s.eat_until(['(', '\n']).split(" or ").map(str::trim).collect(); + if !types.iter().all(|ty| type_index(ty) != usize::MAX) { + panic!( + "unknown type in method {} parameter {}", + name, + types.iter().find(|ty| type_index(ty) == usize::MAX).unwrap() + ) + } + + if name == "returns" { + returns = types; + continue; + } + + s.expect('('); + + let mut named = false; + let mut positional = false; + let mut required = false; + let mut variadic = false; + for part in s.eat_until(')').split(',').map(str::trim) { + match part { + "named" => named = true, + "positional" => positional = true, + "required" => required = true, + "variadic" => variadic = true, + _ => panic!("unknown parameter flag {:?}", part), + } + } + + s.expect(')'); + + params.push(ParamModel { + name, + details: Html::markdown(resolver, s.eat_until("\n- ").trim()), + example: None, + types, + strings: vec![], + default: None, + positional, + named, + required, + variadic, + settable: false, + }); + } + + MethodModel { + name, + details: Html::markdown(resolver, docs), + params, + returns, + } +} + +/// Produce an outline for a type page. +fn type_outline(model: &TypeModel) -> Vec { + let mut outline = vec![OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }]; + + outline.extend(methods_outline(&model.methods)); + outline +} + +/// Produce an outline for a type's method. +fn methods_outline(methods: &[MethodModel]) -> Option { + (!methods.is_empty()).then(|| OutlineItem { + id: "methods".into(), + name: "Methods".into(), + children: methods.iter().map(method_outline).collect(), + }) +} + +/// Produce an outline for a type's method. +fn method_outline(model: &MethodModel) -> OutlineItem { + OutlineItem { + id: format!("methods-{}", urlify(model.name)), + name: model.name.into(), + children: model + .params + .iter() + .map(|param| OutlineItem { + id: format!( + "methods-{}-parameters-{}", + urlify(model.name), + urlify(param.name) + ), + name: param.name.into(), + children: vec![], + }) + .collect(), + } +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct SymbolsModel { + pub name: &'static str, + pub details: Html, + pub list: Vec, +} + +/// Details about a symbol. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolModel { + pub name: String, + pub shorthand: Option<&'static str>, + pub codepoint: u32, + pub accent: bool, + pub unicode_name: Option, + pub alternates: Vec, +} + +/// Create a page for symbols. +fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel { + let module = module(&LIBRARY.global, name).unwrap(); + + let mut list = vec![]; + for (name, value) in module.scope().iter() { + let Value::Symbol(symbol) = value else { continue }; + let complete = |variant: &str| { + if variant.is_empty() { + name.into() + } else { + format!("{}.{}", name, variant) + } + }; + + for (variant, c) in symbol.variants() { + list.push(SymbolModel { + name: complete(variant), + shorthand: typst::syntax::ast::Shorthand::LIST + .iter() + .copied() + .find(|&(_, x)| x == c) + .map(|(s, _)| s), + codepoint: c as u32, + accent: typst::eval::Symbol::combining_accent(c).is_some(), + unicode_name: unicode_names2::name(c) + .map(|s| s.to_string().to_title_case()), + alternates: symbol + .variants() + .filter(|(other, _)| other != &variant) + .map(|(other, _)| complete(other)) + .collect(), + }); + } + } + + let title = match name { + "sym" => "General", + "emoji" => "Emoji", + _ => unreachable!(), + }; + + PageModel { + route: format!("{parent}{name}/"), + title: title.into(), + description: format!("Documentation for the `{name}` module."), + part: None, + outline: vec![], + body: BodyModel::Symbols(SymbolsModel { + name: title, + details: Html::markdown(resolver, details(name)), + list, + }), + children: vec![], + } +} + +/// Data about a collection of functions. +#[derive(Debug, Deserialize)] +struct GroupData { + name: String, + display: String, + functions: Vec, + description: String, +} + +/// Extract a module from another module. +#[track_caller] +fn module<'a>(parent: &'a Module, name: &str) -> Result<&'a Module, String> { + match parent.scope().get(name) { + Some(Value::Module(module)) => Ok(module), + _ => Err(format!("module doesn't contain module `{name}`")), + } +} + +/// Load YAML from a path. +#[track_caller] +fn yaml(path: &str) -> T { + let file = DOCS.get_file(path).unwrap(); + yaml::from_slice(file.contents()).unwrap() +} + +/// Load details for an identifying key. +#[track_caller] +fn details(key: &str) -> &str { + DETAILS + .get(&yaml::Value::String(key.into())) + .and_then(|value| value.as_str()) + .unwrap_or_else(|| panic!("missing details for {key}")) +} + +/// Turn a title into an URL fragment. +pub fn urlify(title: &str) -> String { + title + .chars() + .map(|c| c.to_ascii_lowercase()) + .map(|c| match c { + 'a'..='z' | '0'..='9' => c, + _ => '-', + }) + .collect() +} + +/// Extract the first line of documentation. +fn oneliner(docs: &str) -> &str { + docs.lines().next().unwrap_or_default() +} + +/// The order of types in the documentation. +fn type_index(ty: &str) -> usize { + TYPE_ORDER.iter().position(|&v| v == ty).unwrap_or(usize::MAX) +} + +const TYPE_ORDER: &[&str] = &[ + "any", + "none", + "auto", + "boolean", + "integer", + "float", + "length", + "angle", + "ratio", + "relative length", + "fraction", + "color", + "datetime", + "string", + "regex", + "label", + "content", + "array", + "dictionary", + "function", + "arguments", + "location", + "dir", + "alignment", + "2d alignment", + "selector", + "stroke", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_docs() { + provide(&TestResolver); + } + + struct TestResolver; + + impl Resolver for TestResolver { + fn link(&self, _: &str) -> Option { + None + } + + fn example(&self, _: Html, _: &[Frame]) -> Html { + Html::new(String::new()) + } + + fn image(&self, _: &str, _: &[u8]) -> String { + String::new() + } + + fn commits(&self, _: &str, _: &str) -> Vec { + vec![] + } + } +} diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml new file mode 100644 index 00000000..23c37d75 --- /dev/null +++ b/crates/typst-library/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "typst-library" +description = "The standard library for Typst." +categories = ["compilers", "science"] +keywords = ["typst"] +version.workspace = true +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true + +[lib] +test = false +doctest = false +bench = false + +[dependencies] +typst = { path = "../typst" } +az = "1.2" +chinese-number = { version = "0.7.2", default-features = false, features = ["number-to-chinese"] } +comemo = "0.3" +csv = "1" +ecow = "0.1" +hayagriva = "0.3" +hypher = "0.1" +icu_properties = { version = "1.2.0", features = ["serde"] } +icu_provider = { version = "1.2.0", features = ["sync"] } +icu_provider_adapters = "1.2.0" +icu_provider_blob = "1.2.0" +icu_segmenter = { version = "1.2.1", features = ["serde"] } +kurbo = "0.9" +lipsum = "0.9" +log = "0.4" +once_cell = "1" +roxmltree = "0.18" +rustybuzz = "0.7" +serde_json = "1" +serde_yaml = "0.8" +smallvec = "1.10" +syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy"] } +time = { version = "0.3.20", features = ["formatting"] } +toml = { version = "0.7.3", default-features = false, features = ["parse"] } +tracing = "0.1.37" +ttf-parser = "0.18.1" +typed-arena = "2" +unicode-bidi = "0.3.13" +unicode-math-class = "0.1" +unicode-script = "0.5" +unicode-segmentation = "1" diff --git a/crates/typst-library/assets/cj_linebreak_data.postcard b/crates/typst-library/assets/cj_linebreak_data.postcard new file mode 100644 index 00000000..910dd167 Binary files /dev/null and b/crates/typst-library/assets/cj_linebreak_data.postcard differ diff --git a/crates/typst-library/assets/icudata.postcard b/crates/typst-library/assets/icudata.postcard new file mode 100644 index 00000000..e910bf8e Binary files /dev/null and b/crates/typst-library/assets/icudata.postcard differ diff --git a/crates/typst-library/assets/syntect.bin b/crates/typst-library/assets/syntect.bin new file mode 100644 index 00000000..043602a4 Binary files /dev/null and b/crates/typst-library/assets/syntect.bin differ diff --git a/crates/typst-library/src/compute/calc.rs b/crates/typst-library/src/compute/calc.rs new file mode 100644 index 00000000..81715007 --- /dev/null +++ b/crates/typst-library/src/compute/calc.rs @@ -0,0 +1,1024 @@ +//! Calculations and processing of numeric values. + +use std::cmp; +use std::cmp::Ordering; +use std::ops::{Div, Rem}; + +use typst::eval::{Module, Scope}; + +use crate::prelude::*; + +/// A module with computational functions. +pub fn module() -> Module { + let mut scope = Scope::new(); + scope.define("abs", abs_func()); + scope.define("pow", pow_func()); + scope.define("exp", exp_func()); + scope.define("sqrt", sqrt_func()); + scope.define("sin", sin_func()); + scope.define("cos", cos_func()); + scope.define("tan", tan_func()); + scope.define("asin", asin_func()); + scope.define("acos", acos_func()); + scope.define("atan", atan_func()); + scope.define("atan2", atan2_func()); + scope.define("sinh", sinh_func()); + scope.define("cosh", cosh_func()); + scope.define("tanh", tanh_func()); + scope.define("log", log_func()); + scope.define("ln", ln_func()); + scope.define("fact", fact_func()); + scope.define("perm", perm_func()); + scope.define("binom", binom_func()); + scope.define("gcd", gcd_func()); + scope.define("lcm", lcm_func()); + scope.define("floor", floor_func()); + scope.define("ceil", ceil_func()); + scope.define("trunc", trunc_func()); + scope.define("fract", fract_func()); + scope.define("round", round_func()); + scope.define("clamp", clamp_func()); + scope.define("min", min_func()); + scope.define("max", max_func()); + scope.define("even", even_func()); + scope.define("odd", odd_func()); + scope.define("rem", rem_func()); + scope.define("quo", quo_func()); + scope.define("inf", f64::INFINITY); + scope.define("nan", f64::NAN); + scope.define("pi", std::f64::consts::PI); + scope.define("e", std::f64::consts::E); + Module::new("calc").with_scope(scope) +} + +/// Calculates the absolute value of a numeric value. +/// +/// ## Example { #example } +/// ```example +/// #calc.abs(-5) \ +/// #calc.abs(5pt - 2cm) \ +/// #calc.abs(2fr) +/// ``` +/// +/// Display: Absolute +/// Category: calculate +#[func] +pub fn abs( + /// The value whose absolute value to calculate. + value: ToAbs, +) -> Value { + value.0 +} + +/// A value of which the absolute value can be taken. +pub struct ToAbs(Value); + +cast! { + ToAbs, + v: i64 => Self(v.abs().into_value()), + v: f64 => Self(v.abs().into_value()), + v: Length => Self(Value::Length(v.try_abs() + .ok_or("cannot take absolute value of this length")?)), + v: Angle => Self(Value::Angle(v.abs())), + v: Ratio => Self(Value::Ratio(v.abs())), + v: Fr => Self(Value::Fraction(v.abs())), +} + +/// Raises a value to some exponent. +/// +/// ## Example { #example } +/// ```example +/// #calc.pow(2, 3) +/// ``` +/// +/// Display: Power +/// Category: calculate +#[func] +pub fn pow( + /// The base of the power. + base: Num, + /// The exponent of the power. + exponent: Spanned, + /// The callsite span. + span: Span, +) -> SourceResult { + match exponent.v { + _ if exponent.v.float() == 0.0 && base.float() == 0.0 => { + bail!(span, "zero to the power of zero is undefined") + } + Num::Int(i) if i32::try_from(i).is_err() => { + bail!(exponent.span, "exponent is too large") + } + Num::Float(f) if !f.is_normal() && f != 0.0 => { + bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN") + } + _ => {} + }; + + let result = match (base, exponent.v) { + (Num::Int(a), Num::Int(b)) if b >= 0 => a + .checked_pow(b as u32) + .map(Num::Int) + .ok_or("the result is too large") + .at(span)?, + (a, b) => Num::Float(if a.float() == std::f64::consts::E { + b.float().exp() + } else if a.float() == 2.0 { + b.float().exp2() + } else if let Num::Int(b) = b { + a.float().powi(b as i32) + } else { + a.float().powf(b.float()) + }), + }; + + if result.float().is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Raises a value to some exponent of e. +/// +/// ## Example { #example } +/// ```example +/// #calc.exp(1) +/// ``` +/// +/// Display: Exponential +/// Category: calculate +#[func] +pub fn exp( + /// The exponent of the power. + exponent: Spanned, + /// The callsite span. + span: Span, +) -> SourceResult { + match exponent.v { + Num::Int(i) if i32::try_from(i).is_err() => { + bail!(exponent.span, "exponent is too large") + } + Num::Float(f) if !f.is_normal() && f != 0.0 => { + bail!(exponent.span, "exponent may not be infinite, subnormal, or NaN") + } + _ => {} + }; + + let result = exponent.v.float().exp(); + if result.is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Extracts the square root of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.sqrt(16) \ +/// #calc.sqrt(2.5) +/// ``` +/// +/// Display: Square Root +/// Category: calculate +#[func] +pub fn sqrt( + /// The number whose square root to calculate. Must be non-negative. + value: Spanned, +) -> SourceResult { + if value.v.float() < 0.0 { + bail!(value.span, "cannot take square root of negative number"); + } + Ok(value.v.float().sqrt()) +} + +/// Calculates the sine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.sin(90deg) == calc.sin(-270deg)) +/// #calc.sin(1.5) \ +/// #calc.sin(90deg) +/// ``` +/// +/// Display: Sine +/// Category: calculate +#[func] +pub fn sin( + /// The angle whose sine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.sin(), + AngleLike::Int(n) => (n as f64).sin(), + AngleLike::Float(n) => n.sin(), + } +} + +/// Calculates the cosine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.cos(90deg) \ +/// #calc.cos(1.5) \ +/// #calc.cos(90deg) +/// ``` +/// +/// Display: Cosine +/// Category: calculate +#[func] +pub fn cos( + /// The angle whose cosine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.cos(), + AngleLike::Int(n) => (n as f64).cos(), + AngleLike::Float(n) => n.cos(), + } +} + +/// Calculates the tangent of an angle. +/// +/// When called with an integer or a float, they will be interpreted as +/// radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.tan(1.5) \ +/// #calc.tan(90deg) +/// ``` +/// +/// Display: Tangent +/// Category: calculate +#[func] +pub fn tan( + /// The angle whose tangent to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.tan(), + AngleLike::Int(n) => (n as f64).tan(), + AngleLike::Float(n) => n.tan(), + } +} + +/// Calculates the arcsine of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.asin(0) \ +/// #calc.asin(1) +/// ``` +/// +/// Display: Arcsine +/// Category: calculate +#[func] +pub fn asin( + /// The number whose arcsine to calculate. Must be between -1 and 1. + value: Spanned, +) -> SourceResult { + let val = value.v.float(); + if val < -1.0 || val > 1.0 { + bail!(value.span, "value must be between -1 and 1"); + } + Ok(Angle::rad(val.asin())) +} + +/// Calculates the arccosine of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.acos(0) \ +/// #calc.acos(1) +/// ``` +/// +/// Display: Arccosine +/// Category: calculate +#[func] +pub fn acos( + /// The number whose arcsine to calculate. Must be between -1 and 1. + value: Spanned, +) -> SourceResult { + let val = value.v.float(); + if val < -1.0 || val > 1.0 { + bail!(value.span, "value must be between -1 and 1"); + } + Ok(Angle::rad(val.acos())) +} + +/// Calculates the arctangent of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.atan(0) \ +/// #calc.atan(1) +/// ``` +/// +/// Display: Arctangent +/// Category: calculate +#[func] +pub fn atan( + /// The number whose arctangent to calculate. + value: Num, +) -> Angle { + Angle::rad(value.float().atan()) +} + +/// Calculates the four-quadrant arctangent of a coordinate. +/// +/// The arguments are `(x, y)`, not `(y, x)`. +/// +/// ## Example { #example } +/// ```example +/// #calc.atan2(1, 1) \ +/// #calc.atan2(-2, -3) +/// ``` +/// +/// Display: Four-quadrant Arctangent +/// Category: calculate +#[func] +pub fn atan2( + /// The X coordinate. + x: Num, + /// The Y coordinate. + y: Num, +) -> Angle { + Angle::rad(f64::atan2(y.float(), x.float())) +} + +/// Calculates the hyperbolic sine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.sinh(0) \ +/// #calc.sinh(45deg) +/// ``` +/// +/// Display: Hyperbolic sine +/// Category: calculate +#[func] +pub fn sinh( + /// The angle whose hyperbolic sine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().sinh(), + AngleLike::Int(n) => (n as f64).sinh(), + AngleLike::Float(n) => n.sinh(), + } +} + +/// Calculates the hyperbolic cosine of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.cosh(0) \ +/// #calc.cosh(45deg) +/// ``` +/// +/// Display: Hyperbolic cosine +/// Category: calculate +#[func] +pub fn cosh( + /// The angle whose hyperbolic cosine to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().cosh(), + AngleLike::Int(n) => (n as f64).cosh(), + AngleLike::Float(n) => n.cosh(), + } +} + +/// Calculates the hyperbolic tangent of an angle. +/// +/// When called with an integer or a float, they will be interpreted as radians. +/// +/// ## Example { #example } +/// ```example +/// #calc.tanh(0) \ +/// #calc.tanh(45deg) +/// ``` +/// +/// Display: Hyperbolic tangent +/// Category: calculate +#[func] +pub fn tanh( + /// The angle whose hyperbolic tangent to calculate. + angle: AngleLike, +) -> f64 { + match angle { + AngleLike::Angle(a) => a.to_rad().tanh(), + AngleLike::Int(n) => (n as f64).tanh(), + AngleLike::Float(n) => n.tanh(), + } +} + +/// Calculates the logarithm of a number. +/// +/// If the base is not specified, the logarithm is calculated in base 10. +/// +/// ## Example { #example } +/// ```example +/// #calc.log(100) +/// ``` +/// +/// Display: Logarithm +/// Category: calculate +#[func] +pub fn log( + /// The number whose logarithm to calculate. Must be strictly positive. + value: Spanned, + /// The base of the logarithm. May not be zero. + #[named] + #[default(Spanned::new(10.0, Span::detached()))] + base: Spanned, + /// The callsite span. + span: Span, +) -> SourceResult { + let number = value.v.float(); + if number <= 0.0 { + bail!(value.span, "value must be strictly positive") + } + + if !base.v.is_normal() { + bail!(base.span, "base may not be zero, NaN, infinite, or subnormal") + } + + let result = if base.v == std::f64::consts::E { + number.ln() + } else if base.v == 2.0 { + number.log2() + } else if base.v == 10.0 { + number.log10() + } else { + number.log(base.v) + }; + + if result.is_infinite() || result.is_nan() { + bail!(span, "the result is not a real number") + } + + Ok(result) +} + +/// Calculates the natural logarithm of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.ln(calc.e) +/// ``` +/// +/// Display: Natural Logarithm +/// Category: calculate +#[func] +pub fn ln( + /// The number whose logarithm to calculate. Must be strictly positive. + value: Spanned, + /// The callsite span. + span: Span, +) -> SourceResult { + let number = value.v.float(); + if number <= 0.0 { + bail!(value.span, "value must be strictly positive") + } + + let result = number.ln(); + if result.is_infinite() { + bail!(span, "result close to -inf") + } + + Ok(result) +} + +/// Calculates the factorial of a number. +/// +/// ## Example { #example } +/// ```example +/// #calc.fact(5) +/// ``` +/// +/// Display: Factorial +/// Category: calculate +#[func] +pub fn fact( + /// The number whose factorial to calculate. Must be non-negative. + number: u64, +) -> StrResult { + Ok(fact_impl(1, number).ok_or("the result is too large")?) +} + +/// Calculates a permutation. +/// +/// ## Example { #example } +/// ```example +/// #calc.perm(10, 5) +/// ``` +/// +/// Display: Permutation +/// Category: calculate +#[func] +pub fn perm( + /// The base number. Must be non-negative. + base: u64, + /// The number of permutations. Must be non-negative. + numbers: u64, +) -> StrResult { + // By convention. + if base < numbers { + return Ok(0); + } + + Ok(fact_impl(base - numbers + 1, base).ok_or("the result is too large")?) +} + +/// Calculates the product of a range of numbers. Used to calculate +/// permutations. Returns None if the result is larger than `i64::MAX` +fn fact_impl(start: u64, end: u64) -> Option { + // By convention + if end + 1 < start { + return Some(0); + } + + let real_start: u64 = cmp::max(1, start); + let mut count: u64 = 1; + for i in real_start..=end { + count = count.checked_mul(i)?; + } + + count.try_into().ok() +} + +/// Calculates a binomial coefficient. +/// +/// ## Example { #example } +/// ```example +/// #calc.binom(10, 5) +/// ``` +/// +/// Display: Binomial +/// Category: calculate +#[func] +pub fn binom( + /// The upper coefficient. Must be non-negative. + n: u64, + /// The lower coefficient. Must be non-negative. + k: u64, +) -> StrResult { + Ok(binom_impl(n, k).ok_or("the result is too large")?) +} + +/// Calculates a binomial coefficient, with `n` the upper coefficient and `k` +/// the lower coefficient. Returns `None` if the result is larger than +/// `i64::MAX` +fn binom_impl(n: u64, k: u64) -> Option { + if k > n { + return Some(0); + } + + // By symmetry + let real_k = cmp::min(n - k, k); + if real_k == 0 { + return Some(1); + } + + let mut result: u64 = 1; + for i in 0..real_k { + result = result.checked_mul(n - i)?.checked_div(i + 1)?; + } + + result.try_into().ok() +} + +/// Calculates the greatest common divisor of two integers. +/// +/// ## Example { #example } +/// ```example +/// #calc.gcd(7, 42) +/// ``` +/// +/// Display: Greatest Common Divisor +/// Category: calculate +#[func] +pub fn gcd( + /// The first integer. + a: i64, + /// The second integer. + b: i64, +) -> i64 { + let (mut a, mut b) = (a, b); + while b != 0 { + let temp = b; + b = a % b; + a = temp; + } + + a.abs() +} + +/// Calculates the least common multiple of two integers. +/// +/// ## Example { #example } +/// ```example +/// #calc.lcm(96, 13) +/// ``` +/// +/// Display: Least Common Multiple +/// Category: calculate +#[func] +pub fn lcm( + /// The first integer. + a: i64, + /// The second integer. + b: i64, +) -> StrResult { + if a == b { + return Ok(a.abs()); + } + + Ok(a.checked_div(gcd(a, b)) + .and_then(|gcd| gcd.checked_mul(b)) + .map(|v| v.abs()) + .ok_or("the return value is too large")?) +} + +/// Rounds a number down to the nearest integer. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.floor(3.14) == 3) +/// #assert(calc.floor(3) == 3) +/// #calc.floor(500.1) +/// ``` +/// +/// Display: Round down +/// Category: calculate +#[func] +pub fn floor( + /// The number to round down. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.floor() as i64, + } +} + +/// Rounds a number up to the nearest integer. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.ceil(3.14) == 4) +/// #assert(calc.ceil(3) == 3) +/// #calc.ceil(500.1) +/// ``` +/// +/// Display: Round up +/// Category: calculate +#[func] +pub fn ceil( + /// The number to round up. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.ceil() as i64, + } +} + +/// Returns the integer part of a number. +/// +/// If the number is already an integer, it is returned unchanged. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.trunc(3) == 3) +/// #assert(calc.trunc(-3.7) == -3) +/// #assert(calc.trunc(15.9) == 15) +/// ``` +/// +/// Display: Truncate +/// Category: calculate +#[func] +pub fn trunc( + /// The number to truncate. + value: Num, +) -> i64 { + match value { + Num::Int(n) => n, + Num::Float(n) => n.trunc() as i64, + } +} + +/// Returns the fractional part of a number. +/// +/// If the number is an integer, returns `0`. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.fract(3) == 0) +/// #calc.fract(-3.1) +/// ``` +/// +/// Display: Fractional +/// Category: calculate +#[func] +pub fn fract( + /// The number to truncate. + value: Num, +) -> Num { + match value { + Num::Int(_) => Num::Int(0), + Num::Float(n) => Num::Float(n.fract()), + } +} + +/// Rounds a number to the nearest integer. +/// +/// Optionally, a number of decimal places can be specified. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.round(3.14) == 3) +/// #assert(calc.round(3.5) == 4) +/// #calc.round(3.1415, digits: 2) +/// ``` +/// +/// Display: Round +/// Category: calculate +#[func] +pub fn round( + /// The number to round. + value: Num, + /// The number of decimal places. + #[named] + #[default(0)] + digits: i64, +) -> Num { + match value { + Num::Int(n) if digits == 0 => Num::Int(n), + _ => { + let n = value.float(); + let factor = 10.0_f64.powi(digits as i32); + Num::Float((n * factor).round() / factor) + } + } +} + +/// Clamps a number between a minimum and maximum value. +/// +/// ## Example { #example } +/// ```example +/// #assert(calc.clamp(5, 0, 10) == 5) +/// #assert(calc.clamp(5, 6, 10) == 6) +/// #calc.clamp(5, 0, 4) +/// ``` +/// +/// Display: Clamp +/// Category: calculate +#[func] +pub fn clamp( + /// The number to clamp. + value: Num, + /// The inclusive minimum value. + min: Num, + /// The inclusive maximum value. + max: Spanned, +) -> SourceResult { + if max.v.float() < min.float() { + bail!(max.span, "max must be greater than or equal to min") + } + Ok(value.apply3(min, max.v, i64::clamp, f64::clamp)) +} + +/// Determines the minimum of a sequence of values. +/// +/// ## Example { #example } +/// ```example +/// #calc.min(1, -3, -5, 20, 3, 6) \ +/// #calc.min("typst", "in", "beta") +/// ``` +/// +/// Display: Minimum +/// Category: calculate +#[func] +pub fn min( + /// The sequence of values from which to extract the minimum. + /// Must not be empty. + #[variadic] + values: Vec>, + /// The callsite span. + span: Span, +) -> SourceResult { + minmax(span, values, Ordering::Less) +} + +/// Determines the maximum of a sequence of values. +/// +/// ## Example { #example } +/// ```example +/// #calc.max(1, -3, -5, 20, 3, 6) \ +/// #calc.max("typst", "in", "beta") +/// ``` +/// +/// Display: Maximum +/// Category: calculate +#[func] +pub fn max( + /// The sequence of values from which to extract the maximum. + /// Must not be empty. + #[variadic] + values: Vec>, + /// The callsite span. + span: Span, +) -> SourceResult { + minmax(span, values, Ordering::Greater) +} + +/// Find the minimum or maximum of a sequence of values. +fn minmax( + span: Span, + values: Vec>, + goal: Ordering, +) -> SourceResult { + let mut iter = values.into_iter(); + let Some(Spanned { v: mut extremum, ..}) = iter.next() else { + bail!(span, "expected at least one value"); + }; + + for Spanned { v, span } in iter { + let ordering = typst::eval::ops::compare(&v, &extremum).at(span)?; + if ordering == goal { + extremum = v; + } + } + + Ok(extremum) +} + +/// Determines whether an integer is even. +/// +/// ## Example { #example } +/// ```example +/// #calc.even(4) \ +/// #calc.even(5) \ +/// #range(10).filter(calc.even) +/// ``` +/// +/// Display: Even +/// Category: calculate +#[func] +pub fn even( + /// The number to check for evenness. + value: i64, +) -> bool { + value % 2 == 0 +} + +/// Determines whether an integer is odd. +/// +/// ## Example { #example } +/// ```example +/// #calc.odd(4) \ +/// #calc.odd(5) \ +/// #range(10).filter(calc.odd) +/// ``` +/// +/// Display: Odd +/// Category: calculate +#[func] +pub fn odd( + /// The number to check for oddness. + value: i64, +) -> bool { + value % 2 != 0 +} + +/// Calculates the remainder of two numbers. +/// +/// ## Example { #example } +/// ```example +/// #calc.rem(20, 6) \ +/// #calc.rem(1.75, 0.5) +/// ``` +/// +/// Display: Remainder +/// Category: calculate +#[func] +pub fn rem( + /// The dividend of the remainder. + dividend: Num, + /// The divisor of the remainder. + divisor: Spanned, +) -> SourceResult { + if divisor.v.float() == 0.0 { + bail!(divisor.span, "divisor must not be zero"); + } + Ok(dividend.apply2(divisor.v, Rem::rem, Rem::rem)) +} + +/// Calculates the quotient of two numbers. +/// +/// ## Example { #example } +/// ```example +/// #calc.quo(14, 5) \ +/// #calc.quo(3.46, 0.5) +/// ``` +/// +/// Display: Quotient +/// Category: calculate +#[func] +pub fn quo( + /// The dividend of the quotient. + dividend: Num, + /// The divisor of the quotient. + divisor: Spanned, +) -> SourceResult { + if divisor.v.float() == 0.0 { + bail!(divisor.span, "divisor must not be zero"); + } + + Ok(floor(dividend.apply2(divisor.v, Div::div, Div::div))) +} + +/// A value which can be passed to functions that work with integers and floats. +#[derive(Debug, Copy, Clone)] +pub enum Num { + Int(i64), + Float(f64), +} + +impl Num { + pub fn apply2( + self, + other: Self, + int: impl FnOnce(i64, i64) -> i64, + float: impl FnOnce(f64, f64) -> f64, + ) -> Num { + match (self, other) { + (Self::Int(a), Self::Int(b)) => Num::Int(int(a, b)), + (a, b) => Num::Float(float(a.float(), b.float())), + } + } + + pub fn apply3( + self, + other: Self, + third: Self, + int: impl FnOnce(i64, i64, i64) -> i64, + float: impl FnOnce(f64, f64, f64) -> f64, + ) -> Num { + match (self, other, third) { + (Self::Int(a), Self::Int(b), Self::Int(c)) => Num::Int(int(a, b, c)), + (a, b, c) => Num::Float(float(a.float(), b.float(), c.float())), + } + } + + pub fn float(self) -> f64 { + match self { + Self::Int(v) => v as f64, + Self::Float(v) => v, + } + } +} + +cast! { + Num, + self => match self { + Self::Int(v) => v.into_value(), + Self::Float(v) => v.into_value(), + }, + v: i64 => Self::Int(v), + v: f64 => Self::Float(v), +} + +/// A value that can be passed to a trigonometric function. +pub enum AngleLike { + Int(i64), + Float(f64), + Angle(Angle), +} + +cast! { + AngleLike, + v: i64 => Self::Int(v), + v: f64 => Self::Float(v), + v: Angle => Self::Angle(v), +} diff --git a/crates/typst-library/src/compute/construct.rs b/crates/typst-library/src/compute/construct.rs new file mode 100644 index 00000000..956212ee --- /dev/null +++ b/crates/typst-library/src/compute/construct.rs @@ -0,0 +1,743 @@ +use std::num::NonZeroI64; +use std::str::FromStr; + +use time::{Month, PrimitiveDateTime}; + +use typst::eval::{Datetime, Regex}; + +use crate::prelude::*; + +/// Converts a value to an integer. +/// +/// - Booleans are converted to `0` or `1`. +/// - Floats are floored to the next 64-bit integer. +/// - Strings are parsed in base 10. +/// +/// ## Example { #example } +/// ```example +/// #int(false) \ +/// #int(true) \ +/// #int(2.7) \ +/// #{ int("27") + int("4") } +/// ``` +/// +/// Display: Integer +/// Category: construct +#[func] +pub fn int( + /// The value that should be converted to an integer. + value: ToInt, +) -> i64 { + value.0 +} + +/// A value that can be cast to an integer. +pub struct ToInt(i64); + +cast! { + ToInt, + v: bool => Self(v as i64), + v: i64 => Self(v), + v: f64 => Self(v as i64), + v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid integer: {}", v))?), +} + +/// Converts a value to a float. +/// +/// - Booleans are converted to `0.0` or `1.0`. +/// - Integers are converted to the closest 64-bit float. +/// - Ratios are divided by 100%. +/// - Strings are parsed in base 10 to the closest 64-bit float. +/// Exponential notation is supported. +/// +/// ## Example { #example } +/// ```example +/// #float(false) \ +/// #float(true) \ +/// #float(4) \ +/// #float(40%) \ +/// #float("2.7") \ +/// #float("1e5") +/// ``` +/// +/// Display: Float +/// Category: construct +#[func] +pub fn float( + /// The value that should be converted to a float. + value: ToFloat, +) -> f64 { + value.0 +} + +/// A value that can be cast to a float. +pub struct ToFloat(f64); + +cast! { + ToFloat, + v: bool => Self(v as i64 as f64), + v: i64 => Self(v as f64), + v: f64 => Self(v), + v: Ratio => Self(v.get()), + v: EcoString => Self(v.parse().map_err(|_| eco_format!("invalid float: {}", v))?), +} + +/// Creates a grayscale color. +/// +/// ## Example { #example } +/// ```example +/// #for x in range(250, step: 50) { +/// box(square(fill: luma(x))) +/// } +/// ``` +/// +/// Display: Luma +/// Category: construct +#[func] +pub fn luma( + /// The gray component. + gray: Component, +) -> Color { + LumaColor::new(gray.0).into() +} + +/// Creates an RGB(A) color. +/// +/// The color is specified in the sRGB color space. +/// +/// _Note:_ While you can specify transparent colors and Typst's preview will +/// render them correctly, the PDF export does not handle them properly at the +/// moment. This will be fixed in the future. +/// +/// ## Example { #example } +/// ```example +/// #square(fill: rgb("#b1f2eb")) +/// #square(fill: rgb(87, 127, 230)) +/// #square(fill: rgb(25%, 13%, 65%)) +/// ``` +/// +/// Display: RGB +/// Category: construct +#[func] +pub fn rgb( + /// The color in hexadecimal notation. + /// + /// Accepts three, four, six or eight hexadecimal digits and optionally + /// a leading hashtag. + /// + /// If this string is given, the individual components should not be given. + /// + /// ```example + /// #text(16pt, rgb("#239dad"))[ + /// *Typst* + /// ] + /// ``` + #[external] + hex: EcoString, + /// The red component. + #[external] + red: Component, + /// The green component. + #[external] + green: Component, + /// The blue component. + #[external] + blue: Component, + /// The alpha component. + #[external] + alpha: Component, + /// The arguments. + args: Args, +) -> SourceResult { + let mut args = args; + Ok(if let Some(string) = args.find::>()? { + match RgbaColor::from_str(&string.v) { + Ok(color) => color.into(), + Err(msg) => bail!(string.span, "{msg}"), + } + } else { + let Component(r) = args.expect("red component")?; + let Component(g) = args.expect("green component")?; + let Component(b) = args.expect("blue component")?; + let Component(a) = args.eat()?.unwrap_or(Component(255)); + RgbaColor::new(r, g, b, a).into() + }) +} + +/// An integer or ratio component. +pub struct Component(u8); + +cast! { + Component, + v: i64 => match v { + 0 ..= 255 => Self(v as u8), + _ => bail!("number must be between 0 and 255"), + }, + v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { + Self((v.get() * 255.0).round() as u8) + } else { + bail!("ratio must be between 0% and 100%"); + }, +} + +/// Creates a new datetime. +/// +/// You can specify the [datetime]($type/datetime) using a year, month, day, +/// hour, minute, and second. You can also get the current date with +/// [`datetime.today`]($func/datetime.today). +/// +/// ## Example +/// ```example +/// #let date = datetime( +/// year: 2012, +/// month: 8, +/// day: 3, +/// ) +/// +/// #date.display() \ +/// #date.display( +/// "[day].[month].[year]" +/// ) +/// ``` +/// +/// ## Format +/// _Note_: Depending on which components of the datetime you specify, Typst +/// will store it in one of the following three ways: +/// * If you specify year, month and day, Typst will store just a date. +/// * If you specify hour, minute and second, Typst will store just a time. +/// * If you specify all of year, month, day, hour, minute and second, Typst +/// will store a full datetime. +/// +/// Depending on how it is stored, the [`display`]($type/datetime.display) +/// method will choose a different formatting by default. +/// +/// Display: Datetime +/// Category: construct +#[func] +#[scope( + scope.define("today", datetime_today_func()); + scope +)] +pub fn datetime( + /// The year of the datetime. + #[named] + year: Option, + /// The month of the datetime. + #[named] + month: Option, + /// The day of the datetime. + #[named] + day: Option, + /// The hour of the datetime. + #[named] + hour: Option, + /// The minute of the datetime. + #[named] + minute: Option, + /// The second of the datetime. + #[named] + second: Option, +) -> StrResult { + let time = match (hour, minute, second) { + (Some(hour), Some(minute), Some(second)) => { + match time::Time::from_hms(hour.0, minute.0, second.0) { + Ok(time) => Some(time), + Err(_) => bail!("time is invalid"), + } + } + (None, None, None) => None, + _ => bail!("time is incomplete"), + }; + + let date = match (year, month, day) { + (Some(year), Some(month), Some(day)) => { + match time::Date::from_calendar_date(year.0, month.0, day.0) { + Ok(date) => Some(date), + Err(_) => bail!("date is invalid"), + } + } + (None, None, None) => None, + _ => bail!("date is incomplete"), + }; + + Ok(match (date, time) { + (Some(date), Some(time)) => { + Datetime::Datetime(PrimitiveDateTime::new(date, time)) + } + (Some(date), None) => Datetime::Date(date), + (None, Some(time)) => Datetime::Time(time), + (None, None) => { + bail!("at least one of date or time must be fully specified") + } + }) +} + +pub struct YearComponent(i32); +pub struct MonthComponent(Month); +pub struct DayComponent(u8); +pub struct HourComponent(u8); +pub struct MinuteComponent(u8); +pub struct SecondComponent(u8); + +cast! { + YearComponent, + v: i32 => Self(v), +} + +cast! { + MonthComponent, + v: u8 => Self(Month::try_from(v).map_err(|_| "month is invalid")?) +} + +cast! { + DayComponent, + v: u8 => Self(v), +} + +cast! { + HourComponent, + v: u8 => Self(v), +} + +cast! { + MinuteComponent, + v: u8 => Self(v), +} + +cast! { + SecondComponent, + v: u8 => Self(v), +} + +/// Returns the current date. +/// +/// Refer to the documentation of the [`display`]($type/datetime.display) method +/// for details on how to affect the formatting of the date. +/// +/// ## Example +/// ```example +/// Today's date is +/// #datetime.today().display(). +/// ``` +/// +/// Display: Today +/// Category: construct +#[func] +pub fn datetime_today( + /// An offset to apply to the current UTC date. If set to `{auto}`, the + /// offset will be the local offset. + #[named] + #[default] + offset: Smart, + /// The virtual machine. + vt: &mut Vt, +) -> StrResult { + Ok(vt + .world + .today(offset.as_custom()) + .ok_or("unable to get the current date")?) +} + +/// Creates a CMYK color. +/// +/// This is useful if you want to target a specific printer. The conversion +/// to RGB for display preview might differ from how your printer reproduces +/// the color. +/// +/// ## Example { #example } +/// ```example +/// #square( +/// fill: cmyk(27%, 0%, 3%, 5%) +/// ) +/// ```` +/// +/// Display: CMYK +/// Category: construct +#[func] +pub fn cmyk( + /// The cyan component. + cyan: RatioComponent, + /// The magenta component. + magenta: RatioComponent, + /// The yellow component. + yellow: RatioComponent, + /// The key component. + key: RatioComponent, +) -> Color { + CmykColor::new(cyan.0, magenta.0, yellow.0, key.0).into() +} + +/// A component that must be a ratio. +pub struct RatioComponent(u8); + +cast! { + RatioComponent, + v: Ratio => if (0.0 ..= 1.0).contains(&v.get()) { + Self((v.get() * 255.0).round() as u8) + } else { + bail!("ratio must be between 0% and 100%"); + }, +} + +/// Creates a custom symbol with modifiers. +/// +/// ## Example { #example } +/// ```example +/// #let envelope = symbol( +/// "🖂", +/// ("stamped", "🖃"), +/// ("stamped.pen", "🖆"), +/// ("lightning", "🖄"), +/// ("fly", "🖅"), +/// ) +/// +/// #envelope +/// #envelope.stamped +/// #envelope.stamped.pen +/// #envelope.lightning +/// #envelope.fly +/// ``` +/// +/// Display: Symbol +/// Category: construct +#[func] +pub fn symbol( + /// The variants of the symbol. + /// + /// Can be a just a string consisting of a single character for the + /// modifierless variant or an array with two strings specifying the modifiers + /// and the symbol. Individual modifiers should be separated by dots. When + /// displaying a symbol, Typst selects the first from the variants that have + /// all attached modifiers and the minimum number of other modifiers. + #[variadic] + variants: Vec>, + /// The callsite span. + span: Span, +) -> SourceResult { + let mut list = Vec::new(); + if variants.is_empty() { + bail!(span, "expected at least one variant"); + } + for Spanned { v, span } in variants { + if list.iter().any(|(prev, _)| &v.0 == prev) { + bail!(span, "duplicate variant"); + } + list.push((v.0, v.1)); + } + Ok(Symbol::runtime(list.into_boxed_slice())) +} + +/// A value that can be cast to a symbol. +pub struct Variant(EcoString, char); + +cast! { + Variant, + c: char => Self(EcoString::new(), c), + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => Self(a.cast()?, b.cast()?), + _ => bail!("point array must contain exactly two entries"), + } + }, +} + +/// Converts a value to a string. +/// +/// - Integers are formatted in base 10. This can be overridden with the +/// optional `base` parameter. +/// - Floats are formatted in base 10 and never in exponential notation. +/// - From labels the name is extracted. +/// +/// If you wish to convert from and to Unicode code points, see +/// [`str.to-unicode`]($func/str.to-unicode) and +/// [`str.from-unicode`]($func/str.from-unicode). +/// +/// ## Example { #example } +/// ```example +/// #str(10) \ +/// #str(4000, base: 16) \ +/// #str(2.7) \ +/// #str(1e8) \ +/// #str() +/// ``` +/// +/// Display: String +/// Category: construct +#[func] +#[scope( + scope.define("to-unicode", str_to_unicode_func()); + scope.define("from-unicode", str_from_unicode_func()); + scope +)] +pub fn str( + /// The value that should be converted to a string. + value: ToStr, + /// The base (radix) to display integers in, between 2 and 36. + #[named] + #[default(Spanned::new(10, Span::detached()))] + base: Spanned, +) -> SourceResult { + Ok(match value { + ToStr::Str(s) => { + if base.v != 10 { + bail!(base.span, "base is only supported for integers"); + } + s + } + ToStr::Int(n) => { + if base.v < 2 || base.v > 36 { + bail!(base.span, "base must be between 2 and 36"); + } + int_to_base(n, base.v).into() + } + }) +} + +/// A value that can be cast to a string. +pub enum ToStr { + /// A string value ready to be used as-is. + Str(Str), + /// An integer about to be formatted in a given base. + Int(i64), +} + +cast! { + ToStr, + v: i64 => Self::Int(v), + v: f64 => Self::Str(format_str!("{}", v)), + v: Label => Self::Str(v.0.into()), + v: Str => Self::Str(v), +} + +/// Format an integer in a base. +fn int_to_base(mut n: i64, base: i64) -> EcoString { + if n == 0 { + return "0".into(); + } + + // In Rust, `format!("{:x}", -14i64)` is not `-e` but `fffffffffffffff2`. + // So we can only use the built-in for decimal, not bin/oct/hex. + if base == 10 { + return eco_format!("{n}"); + } + + // The largest output is `to_base(i64::MIN, 2)`, which is 65 chars long. + const SIZE: usize = 65; + let mut digits = [b'\0'; SIZE]; + let mut i = SIZE; + + // It's tempting to take the absolute value, but this will fail for i64::MIN. + // Instead, we turn n negative, as -i64::MAX is perfectly representable. + let negative = n < 0; + if n > 0 { + n = -n; + } + + while n != 0 { + let digit = char::from_digit(-(n % base) as u32, base as u32); + i -= 1; + digits[i] = digit.unwrap_or('?') as u8; + n /= base; + } + + if negative { + i -= 1; + digits[i] = b'-'; + } + + std::str::from_utf8(&digits[i..]).unwrap_or_default().into() +} + +/// Converts a character into its corresponding code point. +/// +/// ## Example +/// ```example +/// #str.to-unicode("a") \ +/// #"a\u{0300}".codepoints().map(str.to-unicode) +/// ``` +/// +/// Display: String To Unicode +/// Category: construct +#[func] +pub fn str_to_unicode( + /// The character that should be converted. + value: char, +) -> u32 { + value.into() +} + +/// Converts a Unicode code point into its corresponding string. +/// +/// ```example +/// #str.from-unicode(97) +/// ``` +/// +/// Display: String From Unicode +/// Category: construct +#[func] +pub fn str_from_unicode( + /// The code point that should be converted. + value: CodePoint, +) -> Str { + format_str!("{}", value.0) +} + +/// The numeric representation of a single unicode code point. +pub struct CodePoint(char); + +cast! { + CodePoint, + v: i64 => { + Self(v.try_into().ok().and_then(|v: u32| v.try_into().ok()).ok_or_else( + || eco_format!("{:#x} is not a valid codepoint", v), + )?) + }, +} + +/// Creates a label from a string. +/// +/// Inserting a label into content attaches it to the closest previous element +/// that is not a space. Then, the element can be [referenced]($func/ref) and +/// styled through the label. +/// +/// ## Example { #example } +/// ```example +/// #show : set text(blue) +/// #show label("b"): set text(red) +/// +/// = Heading +/// *Strong* #label("b") +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: You can create a label by enclosing +/// its name in angle brackets. This works both in markup and code. +/// +/// Display: Label +/// Category: construct +#[func] +pub fn label( + /// The name of the label. + name: EcoString, +) -> Label { + Label(name) +} + +/// Creates a regular expression from a string. +/// +/// The result can be used as a +/// [show rule selector]($styling/#show-rules) and with +/// [string methods]($type/string) like `find`, `split`, and `replace`. +/// +/// See [the specification of the supported syntax](https://docs.rs/regex/latest/regex/#syntax). +/// +/// ## Example { #example } +/// ```example +/// // Works with show rules. +/// #show regex("\d+"): set text(red) +/// +/// The numbers 1 to 10. +/// +/// // Works with string methods. +/// #("a,b;c" +/// .split(regex("[,;]"))) +/// ``` +/// +/// Display: Regex +/// Category: construct +#[func] +pub fn regex( + /// The regular expression as a string. + /// + /// Most regex escape sequences just work because they are not valid Typst + /// escape sequences. To produce regex escape sequences that are also valid in + /// Typst (e.g. `[\\]`), you need to escape twice. Thus, to match a verbatim + /// backslash, you would need to write `{regex("\\\\")}`. + /// + /// If you need many escape sequences, you can also create a raw element + /// and extract its text to use it for your regular expressions: + /// ```{regex(`\d+\.\d+\.\d+`.text)}```. + regex: Spanned, +) -> SourceResult { + Regex::new(®ex.v).at(regex.span) +} + +/// Creates an array consisting of consecutive integers. +/// +/// If you pass just one positional parameter, it is interpreted as the `end` of +/// the range. If you pass two, they describe the `start` and `end` of the +/// range. +/// +/// ## Example { #example } +/// ```example +/// #range(5) \ +/// #range(2, 5) \ +/// #range(20, step: 4) \ +/// #range(21, step: 4) \ +/// #range(5, 2, step: -1) +/// ``` +/// +/// Display: Range +/// Category: construct +#[func] +pub fn range( + /// The start of the range (inclusive). + #[external] + #[default] + start: i64, + /// The end of the range (exclusive). + #[external] + end: i64, + /// The distance between the generated numbers. + #[named] + #[default(NonZeroI64::new(1).unwrap())] + step: NonZeroI64, + /// The arguments. + args: Args, +) -> SourceResult { + let mut args = args; + let first = args.expect::("end")?; + let (start, end) = match args.eat::()? { + Some(second) => (first, second), + None => (0, first), + }; + + let step = step.get(); + + let mut x = start; + let mut array = Array::new(); + + while x.cmp(&end) == 0.cmp(&step) { + array.push(Value::Int(x)); + x += step; + } + + Ok(array) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_base() { + assert_eq!(&int_to_base(0, 10), "0"); + assert_eq!(&int_to_base(0, 16), "0"); + assert_eq!(&int_to_base(0, 36), "0"); + assert_eq!( + &int_to_base(i64::MAX, 2), + "111111111111111111111111111111111111111111111111111111111111111" + ); + assert_eq!( + &int_to_base(i64::MIN, 2), + "-1000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(&int_to_base(i64::MAX, 10), "9223372036854775807"); + assert_eq!(&int_to_base(i64::MIN, 10), "-9223372036854775808"); + assert_eq!(&int_to_base(i64::MAX, 16), "7fffffffffffffff"); + assert_eq!(&int_to_base(i64::MIN, 16), "-8000000000000000"); + assert_eq!(&int_to_base(i64::MAX, 36), "1y2p0ij32e8e7"); + assert_eq!(&int_to_base(i64::MIN, 36), "-1y2p0ij32e8e8"); + } +} diff --git a/crates/typst-library/src/compute/data.rs b/crates/typst-library/src/compute/data.rs new file mode 100644 index 00000000..6e3a298e --- /dev/null +++ b/crates/typst-library/src/compute/data.rs @@ -0,0 +1,492 @@ +use typst::diag::{format_xml_like_error, FileError}; +use typst::eval::Datetime; + +use crate::prelude::*; + +/// Reads plain text from a file. +/// +/// The file will be read and returned as a string. +/// +/// ## Example { #example } +/// ```example +/// #let text = read("data.html") +/// +/// An example for a HTML file:\ +/// #raw(text, lang: "html") +/// ``` +/// +/// Display: Read +/// Category: data-loading +#[func] +pub fn read( + /// Path to a file. + path: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let text = std::str::from_utf8(&data) + .map_err(|_| "file is not valid utf-8") + .at(span)?; + Ok(text.into()) +} + +/// Reads structured data from a CSV file. +/// +/// The CSV file will be read and parsed into a 2-dimensional array of strings: +/// Each row in the CSV file will be represented as an array of strings, and all +/// rows will be collected into a single array. Header rows will not be +/// stripped. +/// +/// ## Example { #example } +/// ```example +/// #let results = csv("data.csv") +/// +/// #table( +/// columns: 2, +/// [*Condition*], [*Result*], +/// ..results.flatten(), +/// ) +/// ``` +/// +/// Display: CSV +/// Category: data-loading +#[func] +pub fn csv( + /// Path to a CSV file. + path: Spanned, + /// The delimiter that separates columns in the CSV file. + /// Must be a single ASCII character. + #[named] + #[default] + delimiter: Delimiter, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + + let mut builder = csv::ReaderBuilder::new(); + builder.has_headers(false); + builder.delimiter(delimiter.0 as u8); + + let mut reader = builder.from_reader(data.as_slice()); + let mut array = Array::new(); + + for (line, result) in reader.records().enumerate() { + // Original solution use line from error, but that is incorrect with + // `has_headers` set to `false`. See issue: + // https://github.com/BurntSushi/rust-csv/issues/184 + let line = line + 1; // Counting lines from 1 + let row = result.map_err(|err| format_csv_error(err, line)).at(span)?; + let sub = row.into_iter().map(|field| field.into_value()).collect(); + array.push(Value::Array(sub)) + } + + Ok(array) +} + +/// The delimiter to use when parsing CSV files. +pub struct Delimiter(char); + +impl Default for Delimiter { + fn default() -> Self { + Self(',') + } +} + +cast! { + Delimiter, + self => self.0.into_value(), + v: EcoString => { + let mut chars = v.chars(); + let first = chars.next().ok_or("delimiter must not be empty")?; + if chars.next().is_some() { + bail!("delimiter must be a single character"); + } + + if !first.is_ascii() { + bail!("delimiter must be an ASCII character"); + } + + Self(first) + }, +} + +/// Format the user-facing CSV error message. +fn format_csv_error(error: csv::Error, line: usize) -> EcoString { + match error.kind() { + csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(), + csv::ErrorKind::UnequalLengths { expected_len, len, .. } => { + eco_format!( + "failed to parse csv file: found {len} instead of {expected_len} fields in line {line}" + ) + } + _ => "failed to parse csv file".into(), + } +} + +/// Reads structured data from a JSON file. +/// +/// The file must contain a valid JSON object or array. JSON objects will be +/// converted into Typst dictionaries, and JSON arrays will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents, `null` will be converted into `{none}`, and numbers will be +/// converted to floats or integers depending on whether they are whole numbers. +/// +/// The function returns a dictionary or an array, depending on the JSON file. +/// +/// The JSON files in the example contain objects with the keys `temperature`, +/// `unit`, and `weather`. +/// +/// ## Example { #example } +/// ```example +/// #let forecast(day) = block[ +/// #box(square( +/// width: 2cm, +/// inset: 8pt, +/// fill: if day.weather == "sunny" { +/// yellow +/// } else { +/// aqua +/// }, +/// align( +/// bottom + right, +/// strong(day.weather), +/// ), +/// )) +/// #h(6pt) +/// #set text(22pt, baseline: -8pt) +/// #day.temperature °#day.unit +/// ] +/// +/// #forecast(json("monday.json")) +/// #forecast(json("tuesday.json")) +/// ``` +/// +/// Display: JSON +/// Category: data-loading +#[func] +pub fn json( + /// Path to a JSON file. + path: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let value: serde_json::Value = + serde_json::from_slice(&data).map_err(format_json_error).at(span)?; + Ok(convert_json(value)) +} + +/// Convert a JSON value to a Typst value. +fn convert_json(value: serde_json::Value) -> Value { + match value { + serde_json::Value::Null => Value::None, + serde_json::Value::Bool(v) => v.into_value(), + serde_json::Value::Number(v) => match v.as_i64() { + Some(int) => int.into_value(), + None => v.as_f64().unwrap_or(f64::NAN).into_value(), + }, + serde_json::Value::String(v) => v.into_value(), + serde_json::Value::Array(v) => { + v.into_iter().map(convert_json).collect::().into_value() + } + serde_json::Value::Object(v) => v + .into_iter() + .map(|(key, value)| (key.into(), convert_json(value))) + .collect::() + .into_value(), + } +} + +/// Format the user-facing JSON error message. +fn format_json_error(error: serde_json::Error) -> EcoString { + assert!(error.is_syntax() || error.is_eof()); + eco_format!("failed to parse json file: syntax error in line {}", error.line()) +} + +/// Reads structured data from a TOML file. +/// +/// The file must contain a valid TOML table. TOML tables will be +/// converted into Typst dictionaries, and TOML arrays will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents and numbers will be converted to floats or integers depending on +/// whether they are whole numbers. For the time being, datetimes will be +/// converted to strings as Typst does not have a built-in datetime yet. +/// +/// The TOML file in the example consists of a table with the keys `title`, +/// `version`, and `authors`. +/// +/// ## Example { #example } +/// ```example +/// #let details = toml("details.toml") +/// +/// Title: #details.title \ +/// Version: #details.version \ +/// Authors: #(details.authors +/// .join(", ", last: " and ")) +/// ``` +/// +/// Display: TOML +/// Category: data-loading +#[func] +pub fn toml( + /// Path to a TOML file. + path: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + + let raw = std::str::from_utf8(&data) + .map_err(|_| "file is not valid utf-8") + .at(span)?; + + let value: toml::Value = toml::from_str(raw).map_err(format_toml_error).at(span)?; + Ok(convert_toml(value)) +} + +/// Convert a TOML value to a Typst value. +fn convert_toml(value: toml::Value) -> Value { + match value { + toml::Value::String(v) => v.into_value(), + toml::Value::Integer(v) => v.into_value(), + toml::Value::Float(v) => v.into_value(), + toml::Value::Boolean(v) => v.into_value(), + toml::Value::Array(v) => { + v.into_iter().map(convert_toml).collect::().into_value() + } + toml::Value::Table(v) => v + .into_iter() + .map(|(key, value)| (key.into(), convert_toml(value))) + .collect::() + .into_value(), + toml::Value::Datetime(v) => match (v.date, v.time) { + (None, None) => Value::None, + (Some(date), None) => { + Datetime::from_ymd(date.year as i32, date.month, date.day).into_value() + } + (None, Some(time)) => { + Datetime::from_hms(time.hour, time.minute, time.second).into_value() + } + (Some(date), Some(time)) => Datetime::from_ymd_hms( + date.year as i32, + date.month, + date.day, + time.hour, + time.minute, + time.second, + ) + .into_value(), + }, + } +} + +/// Format the user-facing TOML error message. +fn format_toml_error(error: toml::de::Error) -> EcoString { + if let Some(range) = error.span() { + eco_format!( + "failed to parse toml file: {}, index {}-{}", + error.message(), + range.start, + range.end + ) + } else { + eco_format!("failed to parse toml file: {}", error.message()) + } +} + +/// Reads structured data from a YAML file. +/// +/// The file must contain a valid YAML object or array. YAML mappings will be +/// converted into Typst dictionaries, and YAML sequences will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents, null-values (`null`, `~` or empty ``) will be converted into +/// `{none}`, and numbers will be converted to floats or integers depending on +/// whether they are whole numbers. +/// +/// Note that mapping keys that are not a string cause the entry to be +/// discarded. +/// +/// Custom YAML tags are ignored, though the loaded value will still be +/// present. +/// +/// The function returns a dictionary or value or an array, depending on +/// the YAML file. +/// +/// The YAML files in the example contain objects with authors as keys, +/// each with a sequence of their own submapping with the keys +/// "title" and "published" +/// +/// ## Example { #example } +/// ```example +/// #let bookshelf(contents) = { +/// for (author, works) in contents { +/// author +/// for work in works [ +/// - #work.title (#work.published) +/// ] +/// } +/// } +/// +/// #bookshelf( +/// yaml("scifi-authors.yaml") +/// ) +/// ``` +/// +/// Display: YAML +/// Category: data-loading +#[func] +pub fn yaml( + /// Path to a YAML file. + path: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let value: serde_yaml::Value = + serde_yaml::from_slice(&data).map_err(format_yaml_error).at(span)?; + Ok(convert_yaml(value)) +} + +/// Convert a YAML value to a Typst value. +fn convert_yaml(value: serde_yaml::Value) -> Value { + match value { + serde_yaml::Value::Null => Value::None, + serde_yaml::Value::Bool(v) => v.into_value(), + serde_yaml::Value::Number(v) => match v.as_i64() { + Some(int) => int.into_value(), + None => v.as_f64().unwrap_or(f64::NAN).into_value(), + }, + serde_yaml::Value::String(v) => v.into_value(), + serde_yaml::Value::Sequence(v) => { + v.into_iter().map(convert_yaml).collect::().into_value() + } + serde_yaml::Value::Mapping(v) => v + .into_iter() + .map(|(key, value)| (convert_yaml_key(key), convert_yaml(value))) + .filter_map(|(key, value)| key.map(|key| (key, value))) + .collect::() + .into_value(), + } +} + +/// Converts an arbitrary YAML mapping key into a Typst Dict Key. +/// Currently it only does so for strings, everything else +/// returns None +fn convert_yaml_key(key: serde_yaml::Value) -> Option { + match key { + serde_yaml::Value::String(v) => Some(Str::from(v)), + _ => None, + } +} + +/// Format the user-facing YAML error message. +fn format_yaml_error(error: serde_yaml::Error) -> EcoString { + eco_format!("failed to parse yaml file: {}", error.to_string().trim()) +} + +/// Reads structured data from an XML file. +/// +/// The XML file is parsed into an array of dictionaries and strings. XML nodes +/// can be elements or strings. Elements are represented as dictionaries with +/// the the following keys: +/// +/// - `tag`: The name of the element as a string. +/// - `attrs`: A dictionary of the element's attributes as strings. +/// - `children`: An array of the element's child nodes. +/// +/// The XML file in the example contains a root `news` tag with multiple +/// `article` tags. Each article has a `title`, `author`, and `content` tag. The +/// `content` tag contains one or more paragraphs, which are represented as `p` +/// tags. +/// +/// ## Example { #example } +/// ```example +/// #let find-child(elem, tag) = { +/// elem.children +/// .find(e => "tag" in e and e.tag == tag) +/// } +/// +/// #let article(elem) = { +/// let title = find-child(elem, "title") +/// let author = find-child(elem, "author") +/// let pars = find-child(elem, "content") +/// +/// heading(title.children.first()) +/// text(10pt, weight: "medium")[ +/// Published by +/// #author.children.first() +/// ] +/// +/// for p in pars.children { +/// if (type(p) == "dictionary") { +/// parbreak() +/// p.children.first() +/// } +/// } +/// } +/// +/// #let data = xml("example.xml") +/// #for elem in data.first().children { +/// if (type(elem) == "dictionary") { +/// article(elem) +/// } +/// } +/// ``` +/// +/// Display: XML +/// Category: data-loading +#[func] +pub fn xml( + /// Path to an XML file. + path: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: path, span } = path; + let id = vm.location().join(&path).at(span)?; + let data = vm.world().file(id).at(span)?; + let text = std::str::from_utf8(&data).map_err(FileError::from).at(span)?; + let document = roxmltree::Document::parse(text).map_err(format_xml_error).at(span)?; + Ok(convert_xml(document.root())) +} + +/// Convert an XML node to a Typst value. +fn convert_xml(node: roxmltree::Node) -> Value { + if node.is_text() { + return node.text().unwrap_or_default().into_value(); + } + + let children: Array = node.children().map(convert_xml).collect(); + if node.is_root() { + return Value::Array(children); + } + + let tag: Str = node.tag_name().name().into(); + let attrs: Dict = node + .attributes() + .map(|attr| (attr.name().into(), attr.value().into_value())) + .collect(); + + Value::Dict(dict! { + "tag" => tag, + "attrs" => attrs, + "children" => children, + }) +} + +/// Format the user-facing XML error message. +fn format_xml_error(error: roxmltree::Error) -> EcoString { + format_xml_like_error("xml file", error) +} diff --git a/crates/typst-library/src/compute/foundations.rs b/crates/typst-library/src/compute/foundations.rs new file mode 100644 index 00000000..f83d71a0 --- /dev/null +++ b/crates/typst-library/src/compute/foundations.rs @@ -0,0 +1,215 @@ +use crate::prelude::*; + +/// Determines the type of a value. +/// +/// Returns the name of the value's type. +/// +/// ## Example { #example } +/// ```example +/// #type(12) \ +/// #type(14.7) \ +/// #type("hello") \ +/// #type(none) \ +/// #type([Hi]) \ +/// #type(x => x + 1) +/// ``` +/// +/// Display: Type +/// Category: foundations +#[func] +pub fn type_( + /// The value whose type's to determine. + value: Value, +) -> Str { + value.type_name().into() +} + +/// Returns the string representation of a value. +/// +/// When inserted into content, most values are displayed as this representation +/// in monospace with syntax-highlighting. The exceptions are `{none}`, +/// integers, floats, strings, content, and functions. +/// +/// **Note:** This function is for debugging purposes. Its output should not be +/// considered stable and may change at any time! +/// +/// ## Example { #example } +/// ```example +/// #none vs #repr(none) \ +/// #"hello" vs #repr("hello") \ +/// #(1, 2) vs #repr((1, 2)) \ +/// #[*Hi*] vs #repr([*Hi*]) +/// ``` +/// +/// Display: Representation +/// Category: foundations +#[func] +pub fn repr( + /// The value whose string representation to produce. + value: Value, +) -> Str { + value.repr() +} + +/// Fails with an error. +/// +/// ## Example { #example } +/// The code below produces the error `panicked with: "this is wrong"`. +/// ```typ +/// #panic("this is wrong") +/// ``` +/// +/// Display: Panic +/// Category: foundations +#[func] +pub fn panic( + /// The values to panic with. + #[variadic] + values: Vec, +) -> StrResult { + let mut msg = EcoString::from("panicked"); + if !values.is_empty() { + msg.push_str(" with: "); + for (i, value) in values.iter().enumerate() { + if i > 0 { + msg.push_str(", "); + } + msg.push_str(&value.repr()); + } + } + Err(msg) +} + +/// Ensures that a condition is fulfilled. +/// +/// Fails with an error if the condition is not fulfilled. Does not +/// produce any output in the document. +/// +/// If you wish to test equality between two values, see +/// [`assert.eq`]($func/assert.eq) and [`assert.ne`]($func/assert.ne). +/// +/// ## Example { #example } +/// ```typ +/// #assert(1 < 2, message: "math broke") +/// ``` +/// +/// Display: Assert +/// Category: foundations +#[func] +#[scope( + scope.define("eq", assert_eq_func()); + scope.define("ne", assert_ne_func()); + scope +)] +pub fn assert( + /// The condition that must be true for the assertion to pass. + condition: bool, + /// The error message when the assertion fails. + #[named] + message: Option, +) -> StrResult { + if !condition { + if let Some(message) = message { + bail!("assertion failed: {message}"); + } else { + bail!("assertion failed"); + } + } + Ok(NoneValue) +} + +/// Ensures that two values are equal. +/// +/// Fails with an error if the first value is not equal to the second. Does not +/// produce any output in the document. +/// +/// ## Example { #example } +/// ```typ +/// #assert.eq(10, 10) +/// ``` +/// +/// Display: Assert Equals +/// Category: foundations +#[func] +pub fn assert_eq( + /// The first value to compare. + left: Value, + + /// The second value to compare. + right: Value, + + /// An optional message to display on error instead of the representations + /// of the compared values. + #[named] + message: Option, +) -> StrResult { + if left != right { + if let Some(message) = message { + bail!("equality assertion failed: {message}"); + } else { + bail!("equality assertion failed: value {left:?} was not equal to {right:?}"); + } + } + Ok(NoneValue) +} + +/// Ensures that two values are not equal. +/// +/// Fails with an error if the first value is equal to the second. Does not +/// produce any output in the document. +/// +/// ## Example { #example } +/// ```typ +/// #assert.ne(3, 4) +/// ``` +/// +/// Display: Assert Not Equals +/// Category: foundations +#[func] +pub fn assert_ne( + /// The first value to compare. + left: Value, + + /// The second value to compare. + right: Value, + + /// An optional message to display on error instead of the representations + /// of the compared values. + #[named] + message: Option, +) -> StrResult { + if left == right { + if let Some(message) = message { + bail!("inequality assertion failed: {message}"); + } else { + bail!("inequality assertion failed: value {left:?} was equal to {right:?}"); + } + } + Ok(NoneValue) +} + +/// Evaluates a string as Typst code. +/// +/// This function should only be used as a last resort. +/// +/// ## Example { #example } +/// ```example +/// #eval("1 + 1") \ +/// #eval("(1, 2, 3, 4)").len() \ +/// #eval("[*Strong text*]") +/// ``` +/// +/// Display: Evaluate +/// Category: foundations +#[func] +pub fn eval( + /// A string of Typst code to evaluate. + /// + /// The code in the string cannot interact with the file system. + source: Spanned, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let Spanned { v: text, span } = source; + typst::eval::eval_string(vm.world(), &text, span) +} diff --git a/crates/typst-library/src/compute/mod.rs b/crates/typst-library/src/compute/mod.rs new file mode 100644 index 00000000..e9e4870c --- /dev/null +++ b/crates/typst-library/src/compute/mod.rs @@ -0,0 +1,39 @@ +//! Computational functions. + +pub mod calc; +mod construct; +mod data; +mod foundations; + +pub use self::construct::*; +pub use self::data::*; +pub use self::foundations::*; + +use crate::prelude::*; + +/// Hook up all compute definitions. +pub(super) fn define(global: &mut Scope) { + global.define("type", type_func()); + global.define("repr", repr_func()); + global.define("panic", panic_func()); + global.define("assert", assert_func()); + global.define("eval", eval_func()); + global.define("int", int_func()); + global.define("float", float_func()); + global.define("luma", luma_func()); + global.define("rgb", rgb_func()); + global.define("cmyk", cmyk_func()); + global.define("datetime", datetime_func()); + global.define("symbol", symbol_func()); + global.define("str", str_func()); + global.define("label", label_func()); + global.define("regex", regex_func()); + global.define("range", range_func()); + global.define("read", read_func()); + global.define("csv", csv_func()); + global.define("json", json_func()); + global.define("toml", toml_func()); + global.define("yaml", yaml_func()); + global.define("xml", xml_func()); + global.define("calc", calc::module()); +} diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs new file mode 100644 index 00000000..bbfe9f7e --- /dev/null +++ b/crates/typst-library/src/layout/align.rs @@ -0,0 +1,67 @@ +use crate::prelude::*; + +/// Aligns content horizontally and vertically. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// +/// Centered text, a sight to see \ +/// In perfect balance, visually \ +/// Not left nor right, it stands alone \ +/// A work of art, a visual throne +/// ``` +/// +/// Display: Align +/// Category: layout +#[element(Show)] +pub struct AlignElem { + /// The alignment along both axes. + /// + /// Possible values for horizontal alignments are: + /// - `start` + /// - `end` + /// - `left` + /// - `center` + /// - `right` + /// + /// The `start` and `end` alignments are relative to the current [text + /// direction]($func/text.dir). + /// + /// Possible values for vertical alignments are: + /// - `top` + /// - `horizon` + /// - `bottom` + /// + /// To align along both axes at the same time, add the two alignments using + /// the `+` operator to get a `2d alignment`. For example, `top + right` + /// aligns the content to the top right corner. + /// + /// ```example + /// #set page(height: 6cm) + /// #set text(lang: "ar") + /// + /// مثال + /// #align( + /// end + horizon, + /// rect(inset: 12pt)[ركن] + /// ) + /// ``` + #[positional] + #[fold] + #[default(Axes::new(GenAlign::Start, GenAlign::Specific(Align::Top)))] + pub alignment: Axes>, + + /// The content to align. + #[required] + pub body: Content, +} + +impl Show for AlignElem { + #[tracing::instrument(name = "AlignElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { + Ok(self + .body() + .styled(Self::set_alignment(self.alignment(styles).map(Some)))) + } +} diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs new file mode 100644 index 00000000..d2138491 --- /dev/null +++ b/crates/typst-library/src/layout/columns.rs @@ -0,0 +1,172 @@ +use crate::prelude::*; +use crate::text::TextElem; + +/// Separates a region into multiple equally sized columns. +/// +/// The `column` function allows to separate the interior of any container into +/// multiple columns. It will not equalize the height of the columns, instead, +/// the columns will take up the height of their container or the remaining +/// height on the page. The columns function can break across pages if +/// necessary. +/// +/// ## Example { #example } +/// ```example +/// = Towards Advanced Deep Learning +/// +/// #box(height: 68pt, +/// columns(2, gutter: 11pt)[ +/// #set par(justify: true) +/// This research was funded by the +/// National Academy of Sciences. +/// NAoS provided support for field +/// tests and interviews with a +/// grant of up to USD 40.000 for a +/// period of 6 months. +/// ] +/// ) +/// +/// In recent years, deep learning has +/// increasingly been used to solve a +/// variety of problems. +/// ``` +/// +/// Display: Columns +/// Category: layout +#[element(Layout)] +pub struct ColumnsElem { + /// The number of columns. + #[positional] + #[default(NonZeroUsize::new(2).unwrap())] + pub count: NonZeroUsize, + + /// The size of the gutter space between each column. + #[resolve] + #[default(Ratio::new(0.04).into())] + pub gutter: Rel, + + /// The content that should be layouted into the columns. + #[required] + pub body: Content, +} + +impl Layout for ColumnsElem { + #[tracing::instrument(name = "ColumnsElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let body = self.body(); + + // Separating the infinite space into infinite columns does not make + // much sense. + if !regions.size.x.is_finite() { + return body.layout(vt, styles, regions); + } + + // Determine the width of the gutter and each column. + let columns = self.count(styles).get(); + let gutter = self.gutter(styles).relative_to(regions.base().x); + let width = (regions.size.x - gutter * (columns - 1) as f64) / columns as f64; + + let backlog: Vec<_> = std::iter::once(®ions.size.y) + .chain(regions.backlog) + .flat_map(|&height| std::iter::repeat(height).take(columns)) + .skip(1) + .collect(); + + // Create the pod regions. + let pod = Regions { + size: Size::new(width, regions.size.y), + full: regions.full, + backlog: &backlog, + last: regions.last, + expand: Axes::new(true, regions.expand.y), + root: regions.root, + }; + + // Layout the children. + let mut frames = body.layout(vt, styles, pod)?.into_iter(); + let mut finished = vec![]; + + let dir = TextElem::dir_in(styles); + let total_regions = (frames.len() as f32 / columns as f32).ceil() as usize; + + // Stitch together the columns for each region. + for region in regions.iter().take(total_regions) { + // The height should be the parent height if we should expand. + // Otherwise its the maximum column height for the frame. In that + // case, the frame is first created with zero height and then + // resized. + let height = if regions.expand.y { region.y } else { Abs::zero() }; + let mut output = Frame::new(Size::new(regions.size.x, height)); + let mut cursor = Abs::zero(); + + for _ in 0..columns { + let Some(frame) = frames.next() else { break }; + if !regions.expand.y { + output.size_mut().y.set_max(frame.height()); + } + + let width = frame.width(); + let x = if dir == Dir::LTR { + cursor + } else { + regions.size.x - cursor - width + }; + + output.push_frame(Point::with_x(x), frame); + cursor += width + gutter; + } + + finished.push(output); + } + + Ok(Fragment::frames(finished)) + } +} + +/// Forces a column break. +/// +/// The function will behave like a [page break]($func/pagebreak) when used in a +/// single column layout or the last column on a page. Otherwise, content after +/// the column break will be placed in the next column. +/// +/// ## Example { #example } +/// ```example +/// #set page(columns: 2) +/// Preliminary findings from our +/// ongoing research project have +/// revealed a hitherto unknown +/// phenomenon of extraordinary +/// significance. +/// +/// #colbreak() +/// Through rigorous experimentation +/// and analysis, we have discovered +/// a hitherto uncharacterized process +/// that defies our current +/// understanding of the fundamental +/// laws of nature. +/// ``` +/// +/// Display: Column Break +/// Category: layout +#[element(Behave)] +pub struct ColbreakElem { + /// If `{true}`, the column break is skipped if the current column is + /// already empty. + #[default(false)] + pub weak: bool, +} + +impl Behave for ColbreakElem { + fn behaviour(&self) -> Behaviour { + if self.weak(StyleChain::default()) { + Behaviour::Weak(1) + } else { + Behaviour::Destructive + } + } +} diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs new file mode 100644 index 00000000..c79669d0 --- /dev/null +++ b/crates/typst-library/src/layout/container.rs @@ -0,0 +1,497 @@ +use typst::eval::AutoValue; + +use super::VElem; +use crate::layout::Spacing; +use crate::prelude::*; + +/// An inline-level container that sizes content. +/// +/// All elements except inline math, text, and boxes are block-level and cannot +/// occur inside of a paragraph. The box function can be used to integrate such +/// elements into a paragraph. Boxes take the size of their contents by default +/// but can also be sized explicitly. +/// +/// ## Example { #example } +/// ```example +/// Refer to the docs +/// #box( +/// height: 9pt, +/// image("docs.svg") +/// ) +/// for more information. +/// ``` +/// +/// Display: Box +/// Category: layout +#[element(Layout)] +pub struct BoxElem { + /// The width of the box. + /// + /// Boxes can have [fractional]($type/fraction) widths, as the example + /// below demonstrates. + /// + /// _Note:_ Currently, only boxes and only their widths might be fractionally + /// sized within paragraphs. Support for fractionally sized images, shapes, + /// and more might be added in the future. + /// + /// ```example + /// Line in #box(width: 1fr, line(length: 100%)) between. + /// ``` + pub width: Sizing, + + /// The height of the box. + pub height: Smart>, + + /// An amount to shift the box's baseline by. + /// + /// ```example + /// Image: #box(baseline: 40%, image("tiger.jpg", width: 2cm)). + /// ``` + #[resolve] + pub baseline: Rel, + + /// The box's background color. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option, + + /// The box's border color. See the + /// [rectangle's documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Sides>>, + + /// How much to round the box's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners>>, + + /// How much to pad the box's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + pub inset: Sides>>, + + /// How much to expand the box's size without affecting the layout. + /// + /// This is useful to prevent padding from affecting line layout. For a + /// generalized version of the example below, see the documentation for the + /// [raw text's block parameter]($func/raw.block). + /// + /// ```example + /// An inline + /// #box( + /// fill: luma(235), + /// inset: (x: 3pt, y: 0pt), + /// outset: (y: 3pt), + /// radius: 2pt, + /// )[rectangle]. + /// ``` + #[resolve] + #[fold] + pub outset: Sides>>, + + /// Whether to clip the content inside the box. + #[default(false)] + pub clip: bool, + + /// The contents of the box. + #[positional] + pub body: Option, +} + +impl Layout for BoxElem { + #[tracing::instrument(name = "BoxElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let width = match self.width(styles) { + Sizing::Auto => Smart::Auto, + Sizing::Rel(rel) => Smart::Custom(rel), + Sizing::Fr(_) => Smart::Custom(Ratio::one().into()), + }; + + // Resolve the sizing to a concrete size. + let sizing = Axes::new(width, self.height(styles)); + let expand = sizing.as_ref().map(Smart::is_custom); + let size = sizing + .resolve(styles) + .zip(regions.base()) + .map(|(s, b)| s.map(|v| v.relative_to(b))) + .unwrap_or(regions.base()); + + // Apply inset. + let mut body = self.body(styles).unwrap_or_default(); + let inset = self.inset(styles); + if inset.iter().any(|v| !v.is_zero()) { + body = body.padded(inset.map(|side| side.map(Length::from))); + } + + // Select the appropriate base and expansion for the child depending + // on whether it is automatically or relatively sized. + let pod = Regions::one(size, expand); + let mut frame = body.layout(vt, styles, pod)?.into_frame(); + + // Enforce correct size. + *frame.size_mut() = expand.select(size, frame.size()); + + // Apply baseline shift. + let shift = self.baseline(styles).relative_to(frame.height()); + if !shift.is_zero() { + frame.set_baseline(frame.baseline() - shift); + } + + // Clip the contents + if self.clip(styles) { + frame.clip(); + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default)); + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + let outset = self.outset(styles); + let radius = self.radius(styles); + frame.fill_and_stroke(fill, stroke, outset, radius, self.span()); + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) + } +} + +/// A block-level container. +/// +/// Such a container can be used to separate content, size it, and give it a +/// background or border. +/// +/// ## Examples { #examples } +/// With a block, you can give a background to content while still allowing it +/// to break across multiple pages. +/// ```example +/// #set page(height: 100pt) +/// #block( +/// fill: luma(230), +/// inset: 8pt, +/// radius: 4pt, +/// lorem(30), +/// ) +/// ``` +/// +/// Blocks are also useful to force elements that would otherwise be inline to +/// become block-level, especially when writing show rules. +/// ```example +/// #show heading: it => it.body +/// = Blockless +/// More text. +/// +/// #show heading: it => block(it.body) +/// = Blocky +/// More text. +/// ``` +/// +/// Display: Block +/// Category: layout +#[element(Layout)] +pub struct BlockElem { + /// The block's width. + /// + /// ```example + /// #set align(center) + /// #block( + /// width: 60%, + /// inset: 8pt, + /// fill: silver, + /// lorem(10), + /// ) + /// ``` + pub width: Smart>, + + /// The block's height. When the height is larger than the remaining space + /// on a page and [`breakable`]($func/block.breakable) is `{true}`, the + /// block will continue on the next page with the remaining height. + /// + /// ```example + /// #set page(height: 80pt) + /// #set align(center) + /// #block( + /// width: 80%, + /// height: 150%, + /// fill: aqua, + /// ) + /// ``` + pub height: Smart>, + + /// Whether the block can be broken and continue on the next page. + /// + /// ```example + /// #set page(height: 80pt) + /// The following block will + /// jump to its own page. + /// #block( + /// breakable: false, + /// lorem(15), + /// ) + /// ``` + #[default(true)] + pub breakable: bool, + + /// The block's background color. See the + /// [rectangle's documentation]($func/rect.fill) for more details. + pub fill: Option, + + /// The block's border color. See the + /// [rectangle's documentation]($func/rect.stroke) for more details. + #[resolve] + #[fold] + pub stroke: Sides>>, + + /// How much to round the block's corners. See the [rectangle's + /// documentation]($func/rect.radius) for more details. + #[resolve] + #[fold] + pub radius: Corners>>, + + /// How much to pad the block's content. See the [rectangle's + /// documentation]($func/rect.inset) for more details. + #[resolve] + #[fold] + pub inset: Sides>>, + + /// How much to expand the block's size without affecting the layout. See + /// the [rectangle's documentation]($func/rect.outset) for more details. + #[resolve] + #[fold] + pub outset: Sides>>, + + /// The spacing around this block. This is shorthand to set `above` and + /// `below` to the same value. + /// + /// ```example + /// #set align(center) + /// #show math.equation: set block(above: 8pt, below: 16pt) + /// + /// This sum of $x$ and $y$: + /// $ x + y = z $ + /// A second paragraph. + /// ``` + #[external] + #[default(Em::new(1.2).into())] + pub spacing: Spacing, + + /// The spacing between this block and its predecessor. Takes precedence + /// over `spacing`. Can be used in combination with a show rule to adjust + /// the spacing around arbitrary block-level elements. + #[external] + #[default(Em::new(1.2).into())] + pub above: Spacing, + #[internal] + #[parse( + let spacing = args.named("spacing")?; + args.named("above")? + .map(VElem::block_around) + .or_else(|| spacing.map(VElem::block_spacing)) + )] + #[default(VElem::block_spacing(Em::new(1.2).into()))] + pub above: VElem, + + /// The spacing between this block and its successor. Takes precedence + /// over `spacing`. + #[external] + #[default(Em::new(1.2).into())] + pub below: Spacing, + #[internal] + #[parse( + args.named("below")? + .map(VElem::block_around) + .or_else(|| spacing.map(VElem::block_spacing)) + )] + #[default(VElem::block_spacing(Em::new(1.2).into()))] + pub below: VElem, + + /// Whether to clip the content inside the block. + #[default(false)] + pub clip: bool, + + /// The contents of the block. + #[positional] + pub body: Option, + + /// Whether this block must stick to the following one. + /// + /// Use this to prevent page breaks between e.g. a heading and its body. + #[internal] + #[default(false)] + pub sticky: bool, +} + +impl Layout for BlockElem { + #[tracing::instrument(name = "BlockElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + // Apply inset. + let mut body = self.body(styles).unwrap_or_default(); + let inset = self.inset(styles); + if inset.iter().any(|v| !v.is_zero()) { + body = body.clone().padded(inset.map(|side| side.map(Length::from))); + } + + // Resolve the sizing to a concrete size. + let sizing = Axes::new(self.width(styles), self.height(styles)); + let mut expand = sizing.as_ref().map(Smart::is_custom); + let mut size = sizing + .resolve(styles) + .zip(regions.base()) + .map(|(s, b)| s.map(|v| v.relative_to(b))) + .unwrap_or(regions.base()); + + // Layout the child. + let mut frames = if self.breakable(styles) { + // Measure to ensure frames for all regions have the same width. + if sizing.x == Smart::Auto { + let pod = Regions::one(size, Axes::splat(false)); + let frame = body.measure(vt, styles, pod)?.into_frame(); + size.x = frame.width(); + expand.x = true; + } + + let mut pod = regions; + pod.size.x = size.x; + pod.expand = expand; + + if expand.y { + pod.full = size.y; + } + + // Generate backlog for fixed height. + let mut heights = vec![]; + if sizing.y.is_custom() { + let mut remaining = size.y; + for region in regions.iter() { + let limited = region.y.min(remaining); + heights.push(limited); + remaining -= limited; + if Abs::zero().fits(remaining) { + break; + } + } + + if let Some(last) = heights.last_mut() { + *last += remaining; + } + + pod.size.y = heights[0]; + pod.backlog = &heights[1..]; + pod.last = None; + } + + let mut frames = body.layout(vt, styles, pod)?.into_frames(); + for (frame, &height) in frames.iter_mut().zip(&heights) { + *frame.size_mut() = + expand.select(Size::new(size.x, height), frame.size()); + } + frames + } else { + let pod = Regions::one(size, expand); + let mut frames = body.layout(vt, styles, pod)?.into_frames(); + *frames[0].size_mut() = expand.select(size, frames[0].size()); + frames + }; + + // Clip the contents + if self.clip(styles) { + for frame in frames.iter_mut() { + frame.clip(); + } + } + + // Prepare fill and stroke. + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(|s| s.map(PartialStroke::unwrap_or_default)); + + // Add fill and/or stroke. + if fill.is_some() || stroke.iter().any(Option::is_some) { + let mut skip = false; + if let [first, rest @ ..] = frames.as_slice() { + skip = first.is_empty() && rest.iter().any(|frame| !frame.is_empty()); + } + + let outset = self.outset(styles); + let radius = self.radius(styles); + for frame in frames.iter_mut().skip(skip as usize) { + frame.fill_and_stroke( + fill.clone(), + stroke.clone(), + outset, + radius, + self.span(), + ); + } + } + + // Apply metadata. + for frame in &mut frames { + frame.meta(styles, false); + } + + Ok(Fragment::frames(frames)) + } +} + +/// Defines how to size a grid cell along an axis. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Sizing { + /// A track that fits its cell's contents. + Auto, + /// A track size specified in absolute terms and relative to the parent's + /// size. + Rel(Rel), + /// A track size specified as a fraction of the remaining free space in the + /// parent. + Fr(Fr), +} + +impl Sizing { + /// Whether this is fractional sizing. + pub fn is_fractional(self) -> bool { + matches!(self, Self::Fr(_)) + } +} + +impl Default for Sizing { + fn default() -> Self { + Self::Auto + } +} + +impl> From for Sizing { + fn from(spacing: T) -> Self { + match spacing.into() { + Spacing::Rel(rel) => Self::Rel(rel), + Spacing::Fr(fr) => Self::Fr(fr), + } + } +} + +cast! { + Sizing, + self => match self { + Self::Auto => Value::Auto, + Self::Rel(rel) => rel.into_value(), + Self::Fr(fr) => fr.into_value(), + }, + _: AutoValue => Self::Auto, + v: Rel => Self::Rel(v), + v: Fr => Self::Fr(v), +} diff --git a/crates/typst-library/src/layout/enum.rs b/crates/typst-library/src/layout/enum.rs new file mode 100644 index 00000000..d66477fc --- /dev/null +++ b/crates/typst-library/src/layout/enum.rs @@ -0,0 +1,318 @@ +use std::str::FromStr; + +use crate::layout::{BlockElem, ParElem, Sizing, Spacing}; +use crate::meta::{Numbering, NumberingPattern}; +use crate::prelude::*; +use crate::text::TextElem; + +use super::GridLayouter; + +/// A numbered list. +/// +/// Displays a sequence of items vertically and numbers them consecutively. +/// +/// ## Example { #example } +/// ```example +/// Automatically numbered: +/// + Preparations +/// + Analysis +/// + Conclusions +/// +/// Manually numbered: +/// 2. What is the first step? +/// 5. I am confused. +/// + Moving on ... +/// +/// Function call. +/// #enum[First][Second] +/// ``` +/// +/// You can easily switch all your enumerations to a different numbering style +/// with a set rule. +/// ```example +/// #set enum(numbering: "a)") +/// +/// + Starting off ... +/// + Don't forget step two +/// ``` +/// +/// You can also use [`enum.item`]($func/enum.item) to programmatically +/// customize the number of each item in the enumeration: +/// +/// ```example +/// #enum( +/// enum.item(1)[First step], +/// enum.item(5)[Fifth step], +/// enum.item(10)[Tenth step] +/// ) +/// ``` +/// +/// ## Syntax { #syntax } +/// This functions also has dedicated syntax: +/// +/// - Starting a line with a plus sign creates an automatically numbered +/// enumeration item. +/// - Starting a line with a number followed by a dot creates an explicitly +/// numbered enumeration item. +/// +/// Enumeration items can contain multiple paragraphs and other block-level +/// content. All content that is indented more than an item's plus sign or dot +/// becomes part of that item. +/// +/// Display: Numbered List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", EnumItem::func()); + scope +)] +pub struct EnumElem { + /// If this is `{false}`, the items are spaced apart with + /// [enum spacing]($func/enum.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the enumeration more + /// compact, which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// + If an enum has a lot of text, and + /// maybe other inline content, it + /// should not be tight anymore. + /// + /// + To make an enum wide, simply + /// insert a blank line between the + /// items. + /// ``` + #[default(true)] + pub tight: bool, + + /// How to number the enumeration. Accepts a + /// [numbering pattern or function]($func/numbering). + /// + /// If the numbering pattern contains multiple counting symbols, they apply + /// to nested enums. If given a function, the function receives one argument + /// if `full` is `{false}` and multiple arguments if `full` is `{true}`. + /// + /// ```example + /// #set enum(numbering: "1.a)") + /// + Different + /// + Numbering + /// + Nested + /// + Items + /// + Style + /// + /// #set enum(numbering: n => super[#n]) + /// + Superscript + /// + Numbering! + /// ``` + #[default(Numbering::Pattern(NumberingPattern::from_str("1.").unwrap()))] + pub numbering: Numbering, + + /// Which number to start the enumeration with. + /// + /// ```example + /// #enum( + /// start: 3, + /// [Skipping], + /// [Ahead], + /// ) + /// ``` + #[default(1)] + pub start: usize, + + /// Whether to display the full numbering, including the numbers of + /// all parent enumerations. + /// + /// + /// ```example + /// #set enum(numbering: "1.a)", full: true) + /// + Cook + /// + Heat water + /// + Add integredients + /// + Eat + /// ``` + #[default(false)] + pub full: bool, + + /// The indentation of each item. + #[resolve] + pub indent: Length, + + /// The space between the numbering and the body of each item. + #[resolve] + #[default(Em::new(0.5).into())] + pub body_indent: Length, + + /// The spacing between the items of a wide (non-tight) enumeration. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart, + + /// The horizontal alignment that enum numbers should have. + /// + /// By default, this is set to `{end}`, which aligns enum numbers + /// towards end of the current text direction (in left-to-right script, + /// for example, this is the same as `{right}`). The choice of `{end}` + /// for horizontal alignment of enum numbers is usually preferred over + /// `{start}`, as numbers then grow away from the text instead of towards + /// it, avoiding certain visual issues. This option lets you override this + /// behavior, however. + /// + /// ````example + /// #set enum(number-align: start) + /// + /// Here are some powers of two: + /// 1. One + /// 2. Two + /// 4. Four + /// 8. Eight + /// 16. Sixteen + /// 32. Thirty two + /// ```` + #[default(HorizontalAlign(GenAlign::End))] + pub number_align: HorizontalAlign, + + /// The numbered list's items. + /// + /// When using the enum syntax, adjacent items are automatically collected + /// into enumerations, even through constructs like for loops. + /// + /// ```example + /// #for phase in ( + /// "Launch", + /// "Orbit", + /// "Descent", + /// ) [+ #phase] + /// ``` + #[variadic] + pub children: Vec, + + /// The numbers of parent items. + #[internal] + #[fold] + parents: Parent, +} + +impl Layout for EnumElem { + #[tracing::instrument(name = "EnumElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let numbering = self.numbering(styles); + let indent = self.indent(styles); + let body_indent = self.body_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let mut cells = vec![]; + let mut number = self.start(styles); + let mut parents = self.parents(styles); + let full = self.full(styles); + + // Horizontally align based on the given respective parameter. + // Vertically align to the top to avoid inheriting 'horizon' or + // 'bottom' alignment from the context and having the number be + // displaced in relation to the item it refers to. + let number_align: Axes> = + Axes::new(self.number_align(styles).into(), Align::Top.into()).map(Some); + + for item in self.children() { + number = item.number(styles).unwrap_or(number); + + let resolved = if full { + parents.push(number); + let content = numbering.apply_vt(vt, &parents)?.display(); + parents.pop(); + content + } else { + match &numbering { + Numbering::Pattern(pattern) => { + TextElem::packed(pattern.apply_kth(parents.len(), number)) + } + other => other.apply_vt(vt, &[number])?.display(), + } + }; + + // Disable overhang as a workaround to end-aligned dots glitching + // and decreasing spacing between numbers and items. + let resolved = + resolved.aligned(number_align).styled(TextElem::set_overhang(false)); + + cells.push(Content::empty()); + cells.push(resolved); + cells.push(Content::empty()); + cells.push(item.body().styled(Self::set_parents(Parent(number)))); + number = number.saturating_add(1); + } + + let layouter = GridLayouter::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + &cells, + regions, + styles, + ); + + Ok(layouter.layout(vt)?.fragment) + } +} + +/// An enumeration item. +/// +/// Display: Numbered List Item +/// Category: layout +#[element] +pub struct EnumItem { + /// The item's number. + #[positional] + pub number: Option, + + /// The item's body. + #[required] + pub body: Content, +} + +cast! { + EnumItem, + array: Array => { + let mut iter = array.into_iter(); + let (number, body) = match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => (a.cast()?, b.cast()?), + _ => bail!("array must contain exactly two entries"), + }; + Self::new(body).with_number(number) + }, + v: Content => v.to::().cloned().unwrap_or_else(|| Self::new(v.clone())), +} + +struct Parent(usize); + +cast! { + Parent, + self => self.0.into_value(), + v: usize => Self(v), +} + +impl Fold for Parent { + type Output = Vec; + + fn fold(self, mut outer: Self::Output) -> Self::Output { + outer.push(self.0); + outer + } +} diff --git a/crates/typst-library/src/layout/flow.rs b/crates/typst-library/src/layout/flow.rs new file mode 100644 index 00000000..accd092a --- /dev/null +++ b/crates/typst-library/src/layout/flow.rs @@ -0,0 +1,583 @@ +use std::mem; + +use super::{ + AlignElem, BlockElem, ColbreakElem, ColumnsElem, ParElem, PlaceElem, Spacing, VElem, +}; +use crate::meta::{FootnoteElem, FootnoteEntry}; +use crate::prelude::*; +use crate::visualize::{ + CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem, + SquareElem, +}; + +/// Arranges spacing, paragraphs and block-level elements into a flow. +/// +/// This element is responsible for layouting both the top-level content flow +/// and the contents of boxes. +/// +/// Display: Flow +/// Category: layout +#[element(Layout)] +pub struct FlowElem { + /// The children that will be arranges into a flow. + #[variadic] + pub children: Vec, +} + +impl Layout for FlowElem { + #[tracing::instrument(name = "FlowElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let mut layouter = FlowLayouter::new(regions, styles); + + for mut child in &self.children() { + let outer = styles; + let mut styles = styles; + if let Some((elem, map)) = child.to_styled() { + child = elem; + styles = outer.chain(map); + } + + if let Some(elem) = child.to::() { + layouter.layout_spacing(vt, elem, styles)?; + } else if let Some(elem) = child.to::() { + layouter.layout_par(vt, elem, styles)?; + } else if child.is::() + || child.is::() + || child.is::() + || child.is::() + || child.is::() + || child.is::() + || child.is::() + || child.is::() + { + let layoutable = child.with::().unwrap(); + layouter.layout_single(vt, layoutable, styles)?; + } else if child.is::() { + let mut frame = Frame::new(Size::zero()); + frame.meta(styles, true); + layouter.items.push(FlowItem::Frame { + frame, + aligns: Axes::new(Align::Top, Align::Left), + sticky: true, + movable: false, + }); + } else if child.can::() { + layouter.layout_multiple(vt, child, styles)?; + } else if child.is::() { + if !layouter.regions.backlog.is_empty() || layouter.regions.last.is_some() + { + layouter.finish_region()?; + } + } else { + bail!(child.span(), "unexpected flow child"); + } + } + + layouter.finish() + } +} + +/// Performs flow layout. +struct FlowLayouter<'a> { + /// Whether this is the root flow. + root: bool, + /// The regions to layout children into. + regions: Regions<'a>, + /// The shared styles. + styles: StyleChain<'a>, + /// Whether the flow should expand to fill the region. + expand: Axes, + /// The initial size of `regions.size` that was available before we started + /// subtracting. + initial: Size, + /// Whether the last block was a paragraph. + last_was_par: bool, + /// Spacing and layouted blocks for the current region. + items: Vec, + /// Whether we have any footnotes in the current region. + has_footnotes: bool, + /// Footnote configuration. + footnote_config: FootnoteConfig, + /// Finished frames for previous regions. + finished: Vec, +} + +/// Cached footnote configuration. +struct FootnoteConfig { + separator: Content, + clearance: Abs, + gap: Abs, +} + +/// A prepared item in a flow layout. +#[derive(Debug)] +enum FlowItem { + /// Spacing between other items and whether it is weak. + Absolute(Abs, bool), + /// Fractional spacing between other items. + Fractional(Fr), + /// A frame for a layouted block, how to align it, whether it sticks to the + /// item after it (for orphan prevention), and whether it is movable + /// (to keep it together with its footnotes). + Frame { frame: Frame, aligns: Axes, sticky: bool, movable: bool }, + /// An absolutely placed frame. + Placed(Frame), + /// A footnote frame (can also be the separator). + Footnote(Frame), +} + +impl FlowItem { + /// The inherent height of the item. + fn height(&self) -> Abs { + match self { + Self::Absolute(v, _) => *v, + Self::Fractional(_) | Self::Placed(_) => Abs::zero(), + Self::Frame { frame, .. } | Self::Footnote(frame) => frame.height(), + } + } +} + +impl<'a> FlowLayouter<'a> { + /// Create a new flow layouter. + fn new(mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self { + let expand = regions.expand; + + // Disable vertical expansion & root for children. + regions.expand.y = false; + let root = mem::replace(&mut regions.root, false); + + Self { + root, + regions, + styles, + expand, + initial: regions.size, + last_was_par: false, + items: vec![], + has_footnotes: false, + footnote_config: FootnoteConfig { + separator: FootnoteEntry::separator_in(styles), + clearance: FootnoteEntry::clearance_in(styles), + gap: FootnoteEntry::gap_in(styles), + }, + finished: vec![], + } + } + + /// Layout vertical spacing. + #[tracing::instrument(name = "FlowLayouter::layout_spacing", skip_all)] + fn layout_spacing( + &mut self, + vt: &mut Vt, + v: &VElem, + styles: StyleChain, + ) -> SourceResult<()> { + self.layout_item( + vt, + match v.amount() { + Spacing::Rel(rel) => FlowItem::Absolute( + rel.resolve(styles).relative_to(self.initial.y), + v.weakness(styles) > 0, + ), + Spacing::Fr(fr) => FlowItem::Fractional(fr), + }, + ) + } + + /// Layout a paragraph. + #[tracing::instrument(name = "FlowLayouter::layout_par", skip_all)] + fn layout_par( + &mut self, + vt: &mut Vt, + par: &ParElem, + styles: StyleChain, + ) -> SourceResult<()> { + let aligns = AlignElem::alignment_in(styles).resolve(styles); + let leading = ParElem::leading_in(styles); + let consecutive = self.last_was_par; + let lines = par + .layout(vt, styles, consecutive, self.regions.base(), self.regions.expand.x)? + .into_frames(); + + let mut sticky = self.items.len(); + for (i, item) in self.items.iter().enumerate().rev() { + match *item { + FlowItem::Absolute(_, _) => {} + FlowItem::Frame { sticky: true, .. } => sticky = i, + _ => break, + } + } + + if let Some(first) = lines.first() { + if !self.regions.size.y.fits(first.height()) && !self.regions.in_last() { + let carry: Vec<_> = self.items.drain(sticky..).collect(); + self.finish_region()?; + for item in carry { + self.layout_item(vt, item)?; + } + } + } + + for (i, frame) in lines.into_iter().enumerate() { + if i > 0 { + self.layout_item(vt, FlowItem::Absolute(leading, true))?; + } + + self.layout_item( + vt, + FlowItem::Frame { frame, aligns, sticky: false, movable: true }, + )?; + } + + self.last_was_par = true; + Ok(()) + } + + /// Layout into a single region. + #[tracing::instrument(name = "FlowLayouter::layout_single", skip_all)] + fn layout_single( + &mut self, + vt: &mut Vt, + content: &dyn Layout, + styles: StyleChain, + ) -> SourceResult<()> { + let aligns = AlignElem::alignment_in(styles).resolve(styles); + let sticky = BlockElem::sticky_in(styles); + let pod = Regions::one(self.regions.base(), Axes::splat(false)); + let frame = content.layout(vt, styles, pod)?.into_frame(); + self.layout_item(vt, FlowItem::Frame { frame, aligns, sticky, movable: true })?; + self.last_was_par = false; + Ok(()) + } + + /// Layout into multiple regions. + fn layout_multiple( + &mut self, + vt: &mut Vt, + block: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + // Placed elements that are out of flow produce placed items which + // aren't aligned later. + if let Some(placed) = block.to::() { + if placed.out_of_flow(styles) { + let frame = block.layout(vt, styles, self.regions)?.into_frame(); + self.layout_item(vt, FlowItem::Placed(frame))?; + return Ok(()); + } + } else if self.regions.is_full() { + // Skip directly if region is already full. + self.finish_region()?; + } + + // How to align the block. + let aligns = if let Some(align) = block.to::() { + align.alignment(styles) + } else if let Some((_, local)) = block.to_styled() { + AlignElem::alignment_in(styles.chain(local)) + } else { + AlignElem::alignment_in(styles) + } + .resolve(styles); + + // Temporarily delegerate rootness to the columns. + let is_root = self.root; + if is_root && block.is::() { + self.root = false; + self.regions.root = true; + } + + // Layout the block itself. + let sticky = BlockElem::sticky_in(styles); + let fragment = block.layout(vt, styles, self.regions)?; + let mut notes = Vec::new(); + + for (i, frame) in fragment.into_iter().enumerate() { + // Find footnotes in the frame. + if self.root { + find_footnotes(&mut notes, &frame); + } + + if i > 0 { + self.finish_region()?; + } + + self.layout_item( + vt, + FlowItem::Frame { frame, aligns, sticky, movable: false }, + )?; + } + + if self.root && !self.handle_footnotes(vt, &mut notes, false, false)? { + self.finish_region()?; + self.handle_footnotes(vt, &mut notes, false, true)?; + } + + self.root = is_root; + self.regions.root = false; + self.last_was_par = false; + + Ok(()) + } + + /// Layout a finished frame. + #[tracing::instrument(name = "FlowLayouter::layout_item", skip_all)] + fn layout_item(&mut self, vt: &mut Vt, item: FlowItem) -> SourceResult<()> { + match item { + FlowItem::Absolute(v, weak) => { + if weak + && !self + .items + .iter() + .any(|item| matches!(item, FlowItem::Frame { .. })) + { + return Ok(()); + } + self.regions.size.y -= v + } + FlowItem::Fractional(_) => {} + FlowItem::Frame { ref frame, movable, .. } => { + let size = frame.size(); + if !self.regions.size.y.fits(size.y) && !self.regions.in_last() { + self.finish_region()?; + } + + self.regions.size.y -= size.y; + if self.root && movable { + let mut notes = Vec::new(); + find_footnotes(&mut notes, frame); + self.items.push(item); + if !self.handle_footnotes(vt, &mut notes, true, false)? { + let item = self.items.pop(); + self.finish_region()?; + self.items.extend(item); + self.regions.size.y -= size.y; + self.handle_footnotes(vt, &mut notes, true, true)?; + } + return Ok(()); + } + } + FlowItem::Placed(_) => {} + FlowItem::Footnote(_) => {} + } + + self.items.push(item); + Ok(()) + } + + /// Finish the frame for one region. + fn finish_region(&mut self) -> SourceResult<()> { + // Trim weak spacing. + while self + .items + .last() + .map_or(false, |item| matches!(item, FlowItem::Absolute(_, true))) + { + self.items.pop(); + } + + // Determine the used size. + let mut fr = Fr::zero(); + let mut used = Size::zero(); + let mut footnote_height = Abs::zero(); + let mut first_footnote = true; + for item in &self.items { + match item { + FlowItem::Absolute(v, _) => used.y += *v, + FlowItem::Fractional(v) => fr += *v, + FlowItem::Frame { frame, .. } => { + let size = frame.size(); + used.y += size.y; + used.x.set_max(size.x); + } + FlowItem::Placed(_) => {} + FlowItem::Footnote(frame) => { + let size = frame.size(); + footnote_height += size.y; + if !first_footnote { + footnote_height += self.footnote_config.gap; + } + first_footnote = false; + used.x.set_max(size.x); + } + } + } + used.y += footnote_height; + + // Determine the size of the flow in this region depending on whether + // the region expands. Also account for fractional spacing and + // footnotes. + let mut size = self.expand.select(self.initial, used).min(self.initial); + if (fr.get() > 0.0 || self.has_footnotes) && self.initial.y.is_finite() { + size.y = self.initial.y; + } + + let mut output = Frame::new(size); + let mut offset = Abs::zero(); + let mut ruler = Align::Top; + let mut footnote_offset = size.y - footnote_height; + + // Place all frames. + for item in self.items.drain(..) { + match item { + FlowItem::Absolute(v, _) => { + offset += v; + } + FlowItem::Fractional(v) => { + let remaining = self.initial.y - used.y; + offset += v.share(fr, remaining); + } + FlowItem::Frame { frame, aligns, .. } => { + ruler = ruler.max(aligns.y); + let x = aligns.x.position(size.x - frame.width()); + let y = offset + ruler.position(size.y - used.y); + let pos = Point::new(x, y); + offset += frame.height(); + output.push_frame(pos, frame); + } + FlowItem::Footnote(frame) => { + let pos = Point::with_y(footnote_offset); + footnote_offset += frame.height() + self.footnote_config.gap; + output.push_frame(pos, frame); + } + FlowItem::Placed(frame) => { + output.push_frame(Point::zero(), frame); + } + } + } + + // Advance to the next region. + self.finished.push(output); + self.regions.next(); + self.initial = self.regions.size; + self.has_footnotes = false; + Ok(()) + } + + /// Finish layouting and return the resulting fragment. + fn finish(mut self) -> SourceResult { + if self.expand.y { + while !self.regions.backlog.is_empty() { + self.finish_region()?; + } + } + + self.finish_region()?; + Ok(Fragment::frames(self.finished)) + } +} + +impl FlowLayouter<'_> { + /// Processes all footnotes in the frame. + #[tracing::instrument(skip_all)] + fn handle_footnotes( + &mut self, + vt: &mut Vt, + notes: &mut Vec, + movable: bool, + force: bool, + ) -> SourceResult { + let items_len = self.items.len(); + let notes_len = notes.len(); + + // Process footnotes one at a time. + let mut k = 0; + while k < notes.len() { + if notes[k].is_ref() { + k += 1; + continue; + } + + if !self.has_footnotes { + self.layout_footnote_separator(vt)?; + } + + self.regions.size.y -= self.footnote_config.gap; + let frames = FootnoteEntry::new(notes[k].clone()) + .pack() + .layout(vt, self.styles, self.regions.with_root(false))? + .into_frames(); + + // If the entries didn't fit, abort (to keep footnote and entry + // together). + if !force + && (k == 0 || movable) + && frames.first().map_or(false, Frame::is_empty) + { + // Remove existing footnotes attempts because we need to + // move the item to the next page. + notes.truncate(notes_len); + + // Undo region modifications. + for item in self.items.drain(items_len..) { + self.regions.size.y -= item.height(); + } + + return Ok(false); + } + + let prev = notes.len(); + for (i, frame) in frames.into_iter().enumerate() { + find_footnotes(notes, &frame); + if i > 0 { + self.finish_region()?; + self.layout_footnote_separator(vt)?; + self.regions.size.y -= self.footnote_config.gap; + } + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + } + + k += 1; + + // Process the nested notes before dealing with further top-level + // notes. + let nested = notes.len() - prev; + if nested > 0 { + notes[k..].rotate_right(nested); + } + } + + Ok(true) + } + + /// Layout and save the footnote separator, typically a line. + #[tracing::instrument(skip_all)] + fn layout_footnote_separator(&mut self, vt: &mut Vt) -> SourceResult<()> { + let expand = Axes::new(self.regions.expand.x, false); + let pod = Regions::one(self.regions.base(), expand); + let separator = &self.footnote_config.separator; + + let mut frame = separator.layout(vt, self.styles, pod)?.into_frame(); + frame.size_mut().y += self.footnote_config.clearance; + frame.translate(Point::with_y(self.footnote_config.clearance)); + + self.has_footnotes = true; + self.regions.size.y -= frame.height(); + self.items.push(FlowItem::Footnote(frame)); + + Ok(()) + } +} + +/// Finds all footnotes in the frame. +#[tracing::instrument(skip_all)] +fn find_footnotes(notes: &mut Vec, frame: &Frame) { + for (_, item) in frame.items() { + match item { + FrameItem::Group(group) => find_footnotes(notes, &group.frame), + FrameItem::Meta(Meta::Elem(content), _) + if !notes.iter().any(|note| note.0.location() == content.location()) => + { + let Some(footnote) = content.to::() else { continue }; + notes.push(footnote.clone()); + } + _ => {} + } + } +} diff --git a/crates/typst-library/src/layout/fragment.rs b/crates/typst-library/src/layout/fragment.rs new file mode 100644 index 00000000..3550df2a --- /dev/null +++ b/crates/typst-library/src/layout/fragment.rs @@ -0,0 +1,87 @@ +use crate::prelude::*; + +/// A partial layout result. +#[derive(Clone)] +pub struct Fragment(Vec); + +impl Fragment { + /// Create a fragment from a single frame. + pub fn frame(frame: Frame) -> Self { + Self(vec![frame]) + } + + /// Create a fragment from multiple frames. + pub fn frames(frames: Vec) -> Self { + Self(frames) + } + + /// Return `true` if the length is 0. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The number of frames in the fragment. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Extract the first and only frame. + /// + /// Panics if there are multiple frames. + #[track_caller] + pub fn into_frame(self) -> Frame { + assert_eq!(self.0.len(), 1, "expected exactly one frame"); + self.0.into_iter().next().unwrap() + } + + /// Extract the frames. + pub fn into_frames(self) -> Vec { + self.0 + } + + /// Iterate over the contained frames. + pub fn iter(&self) -> std::slice::Iter { + self.0.iter() + } + + /// Iterate over the contained frames. + pub fn iter_mut(&mut self) -> std::slice::IterMut { + self.0.iter_mut() + } +} + +impl Debug for Fragment { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self.0.as_slice() { + [frame] => frame.fmt(f), + frames => frames.fmt(f), + } + } +} + +impl IntoIterator for Fragment { + type Item = Frame; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a Fragment { + type Item = &'a Frame; + type IntoIter = std::slice::Iter<'a, Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl<'a> IntoIterator for &'a mut Fragment { + type Item = &'a mut Frame; + type IntoIter = std::slice::IterMut<'a, Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut() + } +} diff --git a/crates/typst-library/src/layout/grid.rs b/crates/typst-library/src/layout/grid.rs new file mode 100644 index 00000000..4f5175e9 --- /dev/null +++ b/crates/typst-library/src/layout/grid.rs @@ -0,0 +1,708 @@ +use crate::prelude::*; +use crate::text::TextElem; + +use super::Sizing; + +/// Arranges content in a grid. +/// +/// The grid element allows you to arrange content in a grid. You can define the +/// number of rows and columns, as well as the size of the gutters between them. +/// There are multiple sizing modes for columns and rows that can be used to +/// create complex layouts. +/// +/// The sizing of the grid is determined by the track sizes specified in the +/// arguments. Because each of the sizing parameters accepts the same values, we +/// will explain them just once, here. Each sizing argument accepts an array of +/// individual track sizes. A track size is either: +/// +/// - `{auto}`: The track will be sized to fit its contents. It will be at most +/// as large as the remaining space. If there is more than one `{auto}` track +/// which, and together they claim more than the available space, the `{auto}` +/// tracks will fairly distribute the available space among themselves. +/// +/// - A fixed or relative length (e.g. `{10pt}` or `{20% - 1cm}`): The track +/// will be exactly of this size. +/// +/// - A fractional length (e.g. `{1fr}`): Once all other tracks have been sized, +/// the remaining space will be divided among the fractional tracks according +/// to their fractions. For example, if there are two fractional tracks, each +/// with a fraction of `{1fr}`, they will each take up half of the remaining +/// space. +/// +/// To specify a single track, the array can be omitted in favor of a single +/// value. To specify multiple `{auto}` tracks, enter the number of tracks +/// instead of an array. For example, `columns:` `{3}` is equivalent to +/// `columns:` `{(auto, auto, auto)}`. +/// +/// ## Example { #example } +/// ```example +/// #set text(10pt, style: "italic") +/// #let cell = rect.with( +/// inset: 8pt, +/// fill: rgb("e4e5ea"), +/// width: 100%, +/// radius: 6pt +/// ) +/// #grid( +/// columns: (60pt, 1fr, 60pt), +/// rows: (60pt, auto), +/// gutter: 3pt, +/// cell(height: 100%)[Easy to learn], +/// cell(height: 100%)[Great output], +/// cell(height: 100%)[Intuitive], +/// cell[Our best Typst yet], +/// cell[ +/// Responsive design in print +/// for everyone +/// ], +/// cell[One more thing...], +/// ) +/// ``` +/// +/// Display: Grid +/// Category: layout +#[element(Layout)] +pub struct GridElem { + /// The column sizes. + /// + /// Either specify a track size array or provide an integer to create a grid + /// with that many `{auto}`-sized columns. Note that opposed to rows and + /// gutters, providing a single track size will only ever create a single + /// column. + pub columns: TrackSizings, + + /// The row sizes. + /// + /// If there are more cells than fit the defined rows, the last row is + /// repeated until there are no more cells. + pub rows: TrackSizings, + + /// The gaps between rows & columns. + /// + /// If there are more gutters than defined sizes, the last gutter is repeated. + #[external] + pub gutter: TrackSizings, + + /// The gaps between columns. Takes precedence over `gutter`. + #[parse( + let gutter = args.named("gutter")?; + args.named("column-gutter")?.or_else(|| gutter.clone()) + )] + pub column_gutter: TrackSizings, + + /// The gaps between rows. Takes precedence over `gutter`. + #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))] + pub row_gutter: TrackSizings, + + /// The contents of the grid cells. + /// + /// The cells are populated in row-major order. + #[variadic] + pub children: Vec, +} + +impl Layout for GridElem { + #[tracing::instrument(name = "GridElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + // Prepare grid layout by unifying content and gutter tracks. + let cells = self.children(); + let layouter = GridLayouter::new( + Axes::new(&self.columns(styles).0, &self.rows(styles).0), + Axes::new(&self.column_gutter(styles).0, &self.row_gutter(styles).0), + &cells, + regions, + styles, + ); + + // Measure the columns and layout the grid row-by-row. + Ok(layouter.layout(vt)?.fragment) + } +} + +/// Track sizing definitions. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct TrackSizings(pub Vec); + +cast! { + TrackSizings, + self => self.0.into_value(), + sizing: Sizing => Self(vec![sizing]), + count: NonZeroUsize => Self(vec![Sizing::Auto; count.get()]), + values: Array => Self(values.into_iter().map(Value::cast).collect::>()?), +} + +/// Performs grid layout. +pub struct GridLayouter<'a> { + /// The grid cells. + cells: &'a [Content], + /// Whether this is an RTL grid. + is_rtl: bool, + /// Whether this grid has gutters. + has_gutter: bool, + /// The column tracks including gutter tracks. + cols: Vec, + /// The row tracks including gutter tracks. + rows: Vec, + /// The regions to layout children into. + regions: Regions<'a>, + /// The inherited styles. + styles: StyleChain<'a>, + /// Resolved column sizes. + rcols: Vec, + /// The sum of `rcols`. + width: Abs, + /// Resolve row sizes, by region. + rrows: Vec>, + /// Rows in the current region. + lrows: Vec, + /// The initial size of the current region before we started subtracting. + initial: Size, + /// Frames for finished regions. + finished: Vec, +} + +/// The resulting sizes of columns and rows in a grid. +#[derive(Debug)] +pub struct GridLayout { + /// The fragment. + pub fragment: Fragment, + /// The column widths. + pub cols: Vec, + /// The heights of the resulting rows segments, by region. + pub rows: Vec>, +} + +/// Details about a resulting row piece. +#[derive(Debug)] +pub struct RowPiece { + /// The height of the segment. + pub height: Abs, + /// The index of the row. + pub y: usize, +} + +/// Produced by initial row layout, auto and relative rows are already finished, +/// fractional rows not yet. +enum Row { + /// Finished row frame of auto or relative row with y index. + Frame(Frame, usize), + /// Fractional row with y index. + Fr(Fr, usize), +} + +impl<'a> GridLayouter<'a> { + /// Create a new grid layouter. + /// + /// This prepares grid layout by unifying content and gutter tracks. + pub fn new( + tracks: Axes<&[Sizing]>, + gutter: Axes<&[Sizing]>, + cells: &'a [Content], + regions: Regions<'a>, + styles: StyleChain<'a>, + ) -> Self { + let mut cols = vec![]; + let mut rows = vec![]; + + // Number of content columns: Always at least one. + let c = tracks.x.len().max(1); + + // Number of content rows: At least as many as given, but also at least + // as many as needed to place each item. + let r = { + let len = cells.len(); + let given = tracks.y.len(); + let needed = len / c + (len % c).clamp(0, 1); + given.max(needed) + }; + + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + let get_or = |tracks: &[_], idx, default| { + tracks.get(idx).or(tracks.last()).copied().unwrap_or(default) + }; + + // Collect content and gutter columns. + for x in 0..c { + cols.push(get_or(tracks.x, x, auto)); + if has_gutter { + cols.push(get_or(gutter.x, x, zero)); + } + } + + // Collect content and gutter rows. + for y in 0..r { + rows.push(get_or(tracks.y, y, auto)); + if has_gutter { + rows.push(get_or(gutter.y, y, zero)); + } + } + + // Remove superfluous gutter tracks. + if has_gutter { + cols.pop(); + rows.pop(); + } + + // Reverse for RTL. + let is_rtl = TextElem::dir_in(styles) == Dir::RTL; + if is_rtl { + cols.reverse(); + } + + // We use these regions for auto row measurement. Since at that moment, + // columns are already sized, we can enable horizontal expansion. + let mut regions = regions; + regions.expand = Axes::new(true, false); + + Self { + cells, + is_rtl, + has_gutter, + rows, + regions, + styles, + rcols: vec![Abs::zero(); cols.len()], + cols, + width: Abs::zero(), + rrows: vec![], + lrows: vec![], + initial: regions.size, + finished: vec![], + } + } + + /// Determines the columns sizes and then layouts the grid row-by-row. + pub fn layout(mut self, vt: &mut Vt) -> SourceResult { + self.measure_columns(vt)?; + + for y in 0..self.rows.len() { + // Skip to next region if current one is full, but only for content + // rows, not for gutter rows. + if self.regions.is_full() && (!self.has_gutter || y % 2 == 0) { + self.finish_region(vt)?; + } + + match self.rows[y] { + Sizing::Auto => self.layout_auto_row(vt, y)?, + Sizing::Rel(v) => self.layout_relative_row(vt, v, y)?, + Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)), + } + } + + self.finish_region(vt)?; + + Ok(GridLayout { + fragment: Fragment::frames(self.finished), + cols: self.rcols, + rows: self.rrows, + }) + } + + /// Determine all column sizes. + #[tracing::instrument(name = "GridLayouter::measure_columns", skip_all)] + fn measure_columns(&mut self, vt: &mut Vt) -> SourceResult<()> { + // Sum of sizes of resolved relative tracks. + let mut rel = Abs::zero(); + + // Sum of fractions of all fractional tracks. + let mut fr = Fr::zero(); + + // Resolve the size of all relative columns and compute the sum of all + // fractional tracks. + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + match col { + Sizing::Auto => {} + Sizing::Rel(v) => { + let resolved = + v.resolve(self.styles).relative_to(self.regions.base().x); + *rcol = resolved; + rel += resolved; + } + Sizing::Fr(v) => fr += v, + } + } + + // Size that is not used by fixed-size columns. + let available = self.regions.size.x - rel; + if available >= Abs::zero() { + // Determine size of auto columns. + let (auto, count) = self.measure_auto_columns(vt, available)?; + + // If there is remaining space, distribute it to fractional columns, + // otherwise shrink auto columns. + let remaining = available - auto; + if remaining >= Abs::zero() { + self.grow_fractional_columns(remaining, fr); + } else { + self.shrink_auto_columns(available, count); + } + } + + // Sum up the resolved column sizes once here. + self.width = self.rcols.iter().sum(); + + Ok(()) + } + + /// Measure the size that is available to auto columns. + fn measure_auto_columns( + &mut self, + vt: &mut Vt, + available: Abs, + ) -> SourceResult<(Abs, usize)> { + let mut auto = Abs::zero(); + let mut count = 0; + + // Determine size of auto columns by laying out all cells in those + // columns, measuring them and finding the largest one. + for (x, &col) in self.cols.iter().enumerate() { + if col != Sizing::Auto { + continue; + } + + let mut resolved = Abs::zero(); + for y in 0..self.rows.len() { + if let Some(cell) = self.cell(x, y) { + // For relative rows, we can already resolve the correct + // base and for auto and fr we could only guess anyway. + let height = match self.rows[y] { + Sizing::Rel(v) => { + v.resolve(self.styles).relative_to(self.regions.base().y) + } + _ => self.regions.base().y, + }; + + let size = Size::new(available, height); + let pod = Regions::one(size, Axes::splat(false)); + let frame = cell.measure(vt, self.styles, pod)?.into_frame(); + resolved.set_max(frame.width()); + } + } + + self.rcols[x] = resolved; + auto += resolved; + count += 1; + } + + Ok((auto, count)) + } + + /// Distribute remaining space to fractional columns. + fn grow_fractional_columns(&mut self, remaining: Abs, fr: Fr) { + if fr.is_zero() { + return; + } + + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + if let Sizing::Fr(v) = col { + *rcol = v.share(fr, remaining); + } + } + } + + /// Redistribute space to auto columns so that each gets a fair share. + fn shrink_auto_columns(&mut self, available: Abs, count: usize) { + let mut last; + let mut fair = -Abs::inf(); + let mut redistribute = available; + let mut overlarge = count; + let mut changed = true; + + // Iteratively remove columns that don't need to be shrunk. + while changed && overlarge > 0 { + changed = false; + last = fair; + fair = redistribute / (overlarge as f64); + + for (&col, &rcol) in self.cols.iter().zip(&self.rcols) { + // Remove an auto column if it is not overlarge (rcol <= fair), + // but also hasn't already been removed (rcol > last). + if col == Sizing::Auto && rcol <= fair && rcol > last { + redistribute -= rcol; + overlarge -= 1; + changed = true; + } + } + } + + // Redistribute space fairly among overlarge columns. + for (&col, rcol) in self.cols.iter().zip(&mut self.rcols) { + if col == Sizing::Auto && *rcol > fair { + *rcol = fair; + } + } + } + + /// Layout a row with automatic height. Such a row may break across multiple + /// regions. + fn layout_auto_row(&mut self, vt: &mut Vt, y: usize) -> SourceResult<()> { + // Determine the size for each region of the row. If the first region + // ends up empty for some column, skip the region and remeasure. + let mut resolved = match self.measure_auto_row(vt, y, true)? { + Some(resolved) => resolved, + None => { + self.finish_region(vt)?; + self.measure_auto_row(vt, y, false)?.unwrap() + } + }; + + // Nothing to layout. + if resolved.is_empty() { + return Ok(()); + } + + // Layout into a single region. + if let &[first] = resolved.as_slice() { + let frame = self.layout_single_row(vt, first, y)?; + self.push_row(frame, y); + return Ok(()); + } + + // Expand all but the last region. + // Skip the first region if the space is eaten up by an fr row. + let len = resolved.len(); + for (region, target) in self + .regions + .iter() + .zip(&mut resolved[..len - 1]) + .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + { + target.set_max(region.y); + } + + // Layout into multiple regions. + let fragment = self.layout_multi_row(vt, &resolved, y)?; + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + self.push_row(frame, y); + if i + 1 < len { + self.finish_region(vt)?; + } + } + + Ok(()) + } + + /// Measure the regions sizes of an auto row. The option is always `Some(_)` + /// if `can_skip` is false. + fn measure_auto_row( + &mut self, + vt: &mut Vt, + y: usize, + can_skip: bool, + ) -> SourceResult>> { + let mut resolved: Vec = vec![]; + + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + let mut pod = self.regions; + pod.size.x = rcol; + + let frames = cell.measure(vt, self.styles, pod)?.into_frames(); + + // Skip the first region if one cell in it is empty. Then, + // remeasure. + if let [first, rest @ ..] = frames.as_slice() { + if can_skip + && first.is_empty() + && rest.iter().any(|frame| !frame.is_empty()) + { + return Ok(None); + } + } + + let mut sizes = frames.iter().map(|frame| frame.height()); + for (target, size) in resolved.iter_mut().zip(&mut sizes) { + target.set_max(size); + } + + // New heights are maximal by virtue of being new. Note that + // this extend only uses the rest of the sizes iterator. + resolved.extend(sizes); + } + } + + Ok(Some(resolved)) + } + + /// Layout a row with relative height. Such a row cannot break across + /// multiple regions, but it may force a region break. + fn layout_relative_row( + &mut self, + vt: &mut Vt, + v: Rel, + y: usize, + ) -> SourceResult<()> { + let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); + let frame = self.layout_single_row(vt, resolved, y)?; + + // Skip to fitting region. + let height = frame.height(); + while !self.regions.size.y.fits(height) && !self.regions.in_last() { + self.finish_region(vt)?; + + // Don't skip multiple regions for gutter and don't push a row. + if self.has_gutter && y % 2 == 1 { + return Ok(()); + } + } + + self.push_row(frame, y); + + Ok(()) + } + + /// Layout a row with fixed height and return its frame. + fn layout_single_row( + &mut self, + vt: &mut Vt, + height: Abs, + y: usize, + ) -> SourceResult { + let mut output = Frame::new(Size::new(self.width, height)); + let mut pos = Point::zero(); + + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + let size = Size::new(rcol, height); + let mut pod = Regions::one(size, Axes::splat(true)); + if self.rows[y] == Sizing::Auto { + pod.full = self.regions.full; + } + let frame = cell.layout(vt, self.styles, pod)?.into_frame(); + output.push_frame(pos, frame); + } + + pos.x += rcol; + } + + Ok(output) + } + + /// Layout a row spanning multiple regions. + fn layout_multi_row( + &mut self, + vt: &mut Vt, + heights: &[Abs], + y: usize, + ) -> SourceResult { + // Prepare frames. + let mut outputs: Vec<_> = heights + .iter() + .map(|&h| Frame::new(Size::new(self.width, h))) + .collect(); + + // Prepare regions. + let size = Size::new(self.width, heights[0]); + let mut pod = Regions::one(size, Axes::splat(true)); + pod.full = self.regions.full; + pod.backlog = &heights[1..]; + + // Layout the row. + let mut pos = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate() { + if let Some(cell) = self.cell(x, y) { + pod.size.x = rcol; + + // Push the layouted frames into the individual output frames. + let fragment = cell.layout(vt, self.styles, pod)?; + for (output, frame) in outputs.iter_mut().zip(fragment) { + output.push_frame(pos, frame); + } + } + + pos.x += rcol; + } + + Ok(Fragment::frames(outputs)) + } + + /// Push a row frame into the current region. + fn push_row(&mut self, frame: Frame, y: usize) { + self.regions.size.y -= frame.height(); + self.lrows.push(Row::Frame(frame, y)); + } + + /// Finish rows for one region. + fn finish_region(&mut self, vt: &mut Vt) -> SourceResult<()> { + // Determine the height of existing rows in the region. + let mut used = Abs::zero(); + let mut fr = Fr::zero(); + for row in &self.lrows { + match row { + Row::Frame(frame, _) => used += frame.height(), + Row::Fr(v, _) => fr += *v, + } + } + + // Determine the size of the grid in this region, expanding fully if + // there are fr rows. + let mut size = Size::new(self.width, used).min(self.initial); + if fr.get() > 0.0 && self.initial.y.is_finite() { + size.y = self.initial.y; + } + + // The frame for the region. + let mut output = Frame::new(size); + let mut pos = Point::zero(); + let mut rrows = vec![]; + + // Place finished rows and layout fractional rows. + for row in std::mem::take(&mut self.lrows) { + let (frame, y) = match row { + Row::Frame(frame, y) => (frame, y), + Row::Fr(v, y) => { + let remaining = self.regions.full - used; + let height = v.share(fr, remaining); + (self.layout_single_row(vt, height, y)?, y) + } + }; + + let height = frame.height(); + output.push_frame(pos, frame); + rrows.push(RowPiece { height, y }); + pos.y += height; + } + + self.finished.push(output); + self.rrows.push(rrows); + self.regions.next(); + self.initial = self.regions.size; + + Ok(()) + } + + /// Get the content of the cell in column `x` and row `y`. + /// + /// Returns `None` if it's a gutter cell. + #[track_caller] + fn cell(&self, mut x: usize, y: usize) -> Option<&'a Content> { + assert!(x < self.cols.len()); + assert!(y < self.rows.len()); + + // Columns are reorder, but the cell slice is not. + if self.is_rtl { + x = self.cols.len() - 1 - x; + } + + if self.has_gutter { + // Even columns and rows are children, odd ones are gutter. + if x % 2 == 0 && y % 2 == 0 { + let c = 1 + self.cols.len() / 2; + self.cells.get((y / 2) * c + x / 2) + } else { + None + } + } else { + let c = self.cols.len(); + self.cells.get(y * c + x) + } + } +} diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs new file mode 100644 index 00000000..c6e83e0c --- /dev/null +++ b/crates/typst-library/src/layout/hide.rs @@ -0,0 +1,30 @@ +use crate::prelude::*; + +/// Hides content without affecting layout. +/// +/// The `hide` function allows you to hide content while the layout still 'sees' +/// it. This is useful to create whitespace that is exactly as large as some +/// content. It may also be useful to redact content because its arguments are +/// not included in the output. +/// +/// ## Example { #example } +/// ```example +/// Hello Jane \ +/// #hide[Hello] Joe +/// ``` +/// +/// Display: Hide +/// Category: layout +#[element(Show)] +pub struct HideElem { + /// The content to hide. + #[required] + pub body: Content, +} + +impl Show for HideElem { + #[tracing::instrument(name = "HideElem::show", skip(self))] + fn show(&self, _: &mut Vt, _: StyleChain) -> SourceResult { + Ok(self.body().styled(MetaElem::set_data(vec![Meta::Hide]))) + } +} diff --git a/crates/typst-library/src/layout/list.rs b/crates/typst-library/src/layout/list.rs new file mode 100644 index 00000000..e39ec3f5 --- /dev/null +++ b/crates/typst-library/src/layout/list.rs @@ -0,0 +1,239 @@ +use crate::layout::{BlockElem, ParElem, Sizing, Spacing}; +use crate::prelude::*; +use crate::text::TextElem; + +use super::GridLayouter; + +/// A bullet list. +/// +/// Displays a sequence of items vertically, with each item introduced by a +/// marker. +/// +/// ## Example { #example } +/// ```example +/// - *Content* +/// - Text +/// - Math +/// - Layout +/// - Visualize +/// - Meta +/// - Symbols +/// +/// - *Compute* +/// #list( +/// [Foundations], +/// [Calculate], +/// [Construct], +/// [Data Loading], +/// ) +/// ``` +/// +/// ## Syntax { #syntax } +/// This functions also has dedicated syntax: Start a line with a hyphen, +/// followed by a space to create a list item. A list item can contain multiple +/// paragraphs and other block-level content. All content that is indented +/// more than an item's hyphen becomes part of that item. +/// +/// Display: Bullet List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", ListItem::func()); + scope +)] +pub struct ListElem { + /// If this is `{false}`, the items are spaced apart with [list + /// spacing]($func/list.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the list more compact, + /// which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// - If a list has a lot of text, and + /// maybe other inline content, it + /// should not be tight anymore. + /// + /// - To make a list wide, simply insert + /// a blank line between the items. + /// ``` + #[default(true)] + pub tight: bool, + + /// The marker which introduces each item. + /// + /// Instead of plain content, you can also pass an array with multiple + /// markers that should be used for nested lists. If the list nesting depth + /// exceeds the number of markers, the last one is repeated. For total + /// control, you may pass a function that maps the list's nesting depth + /// (starting from `{0}`) to a desired marker. + /// + /// ```example + /// #set list(marker: [--]) + /// - A more classic list + /// - With en-dashes + /// + /// #set list(marker: ([•], [--])) + /// - Top-level + /// - Nested + /// - Items + /// - Items + /// ``` + #[default(ListMarker::Content(vec![TextElem::packed('•')]))] + pub marker: ListMarker, + + /// The indent of each item. + #[resolve] + pub indent: Length, + + /// The spacing between the marker and the body of each item. + #[resolve] + #[default(Em::new(0.5).into())] + pub body_indent: Length, + + /// The spacing between the items of a wide (non-tight) list. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart, + + /// The bullet list's children. + /// + /// When using the list syntax, adjacent items are automatically collected + /// into lists, even through constructs like for loops. + /// + /// ```example + /// #for letter in "ABC" [ + /// - Letter #letter + /// ] + /// ``` + #[variadic] + pub children: Vec, + + /// The nesting depth. + #[internal] + #[fold] + depth: Depth, +} + +impl Layout for ListElem { + #[tracing::instrument(name = "ListElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let indent = self.indent(styles); + let body_indent = self.body_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let depth = self.depth(styles); + let marker = self + .marker(styles) + .resolve(vt, depth)? + // avoid '#set align' interference with the list + .aligned(Align::LEFT_TOP.into()); + + let mut cells = vec![]; + for item in self.children() { + cells.push(Content::empty()); + cells.push(marker.clone()); + cells.push(Content::empty()); + cells.push(item.body().styled(Self::set_depth(Depth))); + } + + let layouter = GridLayouter::new( + Axes::with_x(&[ + Sizing::Rel(indent.into()), + Sizing::Auto, + Sizing::Rel(body_indent.into()), + Sizing::Auto, + ]), + Axes::with_y(&[gutter.into()]), + &cells, + regions, + styles, + ); + + Ok(layouter.layout(vt)?.fragment) + } +} + +/// A bullet list item. +/// +/// Display: Bullet List Item +/// Category: layout +#[element] +pub struct ListItem { + /// The item's body. + #[required] + pub body: Content, +} + +cast! { + ListItem, + v: Content => v.to::().cloned().unwrap_or_else(|| Self::new(v.clone())), +} + +/// A list's marker. +#[derive(Debug, Clone, Hash)] +pub enum ListMarker { + Content(Vec), + Func(Func), +} + +impl ListMarker { + /// Resolve the marker for the given depth. + fn resolve(&self, vt: &mut Vt, depth: usize) -> SourceResult { + Ok(match self { + Self::Content(list) => { + list.get(depth).or(list.last()).cloned().unwrap_or_default() + } + Self::Func(func) => func.call_vt(vt, [depth])?.display(), + }) + } +} + +cast! { + ListMarker, + self => match self { + Self::Content(vec) => if vec.len() == 1 { + vec.into_iter().next().unwrap().into_value() + } else { + vec.into_value() + }, + Self::Func(func) => func.into_value(), + }, + v: Content => Self::Content(vec![v]), + array: Array => { + if array.is_empty() { + bail!("array must contain at least one marker"); + } + Self::Content(array.into_iter().map(Value::display).collect()) + }, + v: Func => Self::Func(v), +} + +struct Depth; + +cast! { + Depth, + self => Value::None, + _: Value => Self, +} + +impl Fold for Depth { + type Output = usize; + + fn fold(self, outer: Self::Output) -> Self::Output { + outer + 1 + } +} diff --git a/crates/typst-library/src/layout/measure.rs b/crates/typst-library/src/layout/measure.rs new file mode 100644 index 00000000..eb8e509e --- /dev/null +++ b/crates/typst-library/src/layout/measure.rs @@ -0,0 +1,56 @@ +use crate::prelude::*; + +/// Measures the layouted size of content. +/// +/// The `measure` function lets you determine the layouted size of content. +/// Note that an infinite space is assumed, therefore the measured height/width +/// may not necessarily match the final height/width of the measured content. +/// If you want to measure in the current layout dimensions, you can combined +/// `measure` and [`layout`]($func/layout). +/// +/// # Example { #example } +/// The same content can have a different size depending on the styles that +/// are active when it is layouted. For example, in the example below +/// `[#content]` is of course bigger when we increase the font size. +/// +/// ```example +/// #let content = [Hello!] +/// #content +/// #set text(14pt) +/// #content +/// ``` +/// +/// To do a meaningful measurement, you therefore first need to retrieve the +/// active styles with the [`style`]($func/style) function. You can then pass +/// them to the `measure` function. +/// +/// ```example +/// #let thing(body) = style(styles => { +/// let size = measure(body, styles) +/// [Width of "#body" is #size.width] +/// }) +/// +/// #thing[Hey] \ +/// #thing[Welcome] +/// ``` +/// +/// The measure function returns a dictionary with the entries `width` and +/// `height`, both of type [`length`]($type/length). +/// +/// Display: Measure +/// Category: layout +#[func] +pub fn measure( + /// The content whose size to measure. + content: Content, + /// The styles with which to layout the content. + styles: Styles, + /// The virtual machine. + vm: &mut Vm, +) -> SourceResult { + let pod = Regions::one(Axes::splat(Abs::inf()), Axes::splat(false)); + let styles = StyleChain::new(&styles); + let frame = content.measure(&mut vm.vt, styles, pod)?.into_frame(); + let Size { x, y } = frame.size(); + Ok(dict! { "width" => x, "height" => y }) +} diff --git a/crates/typst-library/src/layout/mod.rs b/crates/typst-library/src/layout/mod.rs new file mode 100644 index 00000000..41490eb8 --- /dev/null +++ b/crates/typst-library/src/layout/mod.rs @@ -0,0 +1,709 @@ +//! Composable layouts. + +mod align; +mod columns; +mod container; +#[path = "enum.rs"] +mod enum_; +mod flow; +mod fragment; +mod grid; +mod hide; +mod list; +mod measure; +mod pad; +mod page; +mod par; +mod place; +mod regions; +mod repeat; +mod spacing; +mod stack; +mod table; +mod terms; +mod transform; + +pub use self::align::*; +pub use self::columns::*; +pub use self::container::*; +pub use self::enum_::*; +pub use self::flow::*; +pub use self::fragment::*; +pub use self::grid::*; +pub use self::hide::*; +pub use self::list::*; +pub use self::measure::*; +pub use self::pad::*; +pub use self::page::*; +pub use self::par::*; +pub use self::place::*; +pub use self::regions::*; +pub use self::repeat::*; +pub use self::spacing::*; +pub use self::stack::*; +pub use self::table::*; +pub use self::terms::*; +pub use self::transform::*; + +use std::mem; + +use typed_arena::Arena; +use typst::diag::SourceResult; +use typst::eval::Tracer; +use typst::model::DelayedErrors; +use typst::model::{applicable, realize, StyleVecBuilder}; + +use crate::math::{EquationElem, LayoutMath}; +use crate::meta::DocumentElem; +use crate::prelude::*; +use crate::shared::BehavedBuilder; +use crate::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; +use crate::visualize::{ + CircleElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, RectElem, + SquareElem, +}; + +/// Hook up all layout definitions. +pub(super) fn define(global: &mut Scope) { + global.define("page", PageElem::func()); + global.define("pagebreak", PagebreakElem::func()); + global.define("v", VElem::func()); + global.define("par", ParElem::func()); + global.define("parbreak", ParbreakElem::func()); + global.define("h", HElem::func()); + global.define("box", BoxElem::func()); + global.define("block", BlockElem::func()); + global.define("list", ListElem::func()); + global.define("enum", EnumElem::func()); + global.define("terms", TermsElem::func()); + global.define("table", TableElem::func()); + global.define("stack", StackElem::func()); + global.define("grid", GridElem::func()); + global.define("columns", ColumnsElem::func()); + global.define("colbreak", ColbreakElem::func()); + global.define("place", PlaceElem::func()); + global.define("align", AlignElem::func()); + global.define("pad", PadElem::func()); + global.define("repeat", RepeatElem::func()); + global.define("move", MoveElem::func()); + global.define("scale", ScaleElem::func()); + global.define("rotate", RotateElem::func()); + global.define("hide", HideElem::func()); + global.define("measure", measure_func()); + global.define("ltr", Dir::LTR); + global.define("rtl", Dir::RTL); + global.define("ttb", Dir::TTB); + global.define("btt", Dir::BTT); + global.define("start", GenAlign::Start); + global.define("end", GenAlign::End); + global.define("left", GenAlign::Specific(Align::Left)); + global.define("center", GenAlign::Specific(Align::Center)); + global.define("right", GenAlign::Specific(Align::Right)); + global.define("top", GenAlign::Specific(Align::Top)); + global.define("horizon", GenAlign::Specific(Align::Horizon)); + global.define("bottom", GenAlign::Specific(Align::Bottom)); +} + +/// Root-level layout. +pub trait LayoutRoot { + /// Layout into one frame per page. + fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult; +} + +impl LayoutRoot for Content { + #[tracing::instrument(name = "Content::layout_root", skip_all)] + fn layout_root(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + #[comemo::memoize] + fn cached( + content: &Content, + world: Tracked, + introspector: Tracked, + locator: Tracked, + delayed: TrackedMut, + tracer: TrackedMut, + styles: StyleChain, + ) -> SourceResult { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let scratch = Scratch::default(); + let (realized, styles) = realize_root(&mut vt, &scratch, content, styles)?; + realized + .with::() + .unwrap() + .layout_root(&mut vt, styles) + } + + tracing::info!("Starting layout"); + cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + ) + } +} + +/// Layout into regions. +pub trait Layout { + /// Layout into one frame per region. + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult; + + /// Layout without side effects. + /// + /// This element must be layouted again in the same order for the results to + /// be valid. + #[tracing::instrument(name = "Layout::measure", skip_all)] + fn measure( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let mut locator = Locator::chained(vt.locator.track()); + let mut vt = Vt { + world: vt.world, + introspector: vt.introspector, + locator: &mut locator, + tracer: TrackedMut::reborrow_mut(&mut vt.tracer), + delayed: TrackedMut::reborrow_mut(&mut vt.delayed), + }; + self.layout(&mut vt, styles, regions) + } +} + +impl Layout for Content { + #[tracing::instrument(name = "Content::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + #[allow(clippy::too_many_arguments)] + #[comemo::memoize] + fn cached( + content: &Content, + world: Tracked, + introspector: Tracked, + locator: Tracked, + delayed: TrackedMut, + tracer: TrackedMut, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let scratch = Scratch::default(); + let (realized, styles) = realize_block(&mut vt, &scratch, content, styles)?; + realized + .with::() + .unwrap() + .layout(&mut vt, styles, regions) + } + + tracing::info!("Layouting `Content`"); + + let fragment = cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + regions, + )?; + + vt.locator.visit_frames(&fragment); + Ok(fragment) + } +} + +/// Realize into an element that is capable of root-level layout. +#[tracing::instrument(skip_all)] +fn realize_root<'a>( + vt: &mut Vt, + scratch: &'a Scratch<'a>, + content: &'a Content, + styles: StyleChain<'a>, +) -> SourceResult<(Content, StyleChain<'a>)> { + if content.can::() && !applicable(content, styles) { + return Ok((content.clone(), styles)); + } + + let mut builder = Builder::new(vt, scratch, true); + builder.accept(content, styles)?; + builder.interrupt_page(Some(styles))?; + let (pages, shared) = builder.doc.unwrap().pages.finish(); + Ok((DocumentElem::new(pages.to_vec()).pack(), shared)) +} + +/// Realize into an element that is capable of block-level layout. +#[tracing::instrument(skip_all)] +fn realize_block<'a>( + vt: &mut Vt, + scratch: &'a Scratch<'a>, + content: &'a Content, + styles: StyleChain<'a>, +) -> SourceResult<(Content, StyleChain<'a>)> { + if content.can::() + && !content.is::() + && !content.is::() + && !content.is::() + && !content.is::() + && !content.is::() + && !content.is::() + && !content.is::() + && !content.is::() + && !applicable(content, styles) + { + return Ok((content.clone(), styles)); + } + + let mut builder = Builder::new(vt, scratch, false); + builder.accept(content, styles)?; + builder.interrupt_par()?; + let (children, shared) = builder.flow.0.finish(); + Ok((FlowElem::new(children.to_vec()).pack(), shared)) +} + +/// Builds a document or a flow element from content. +struct Builder<'a, 'v, 't> { + /// The virtual typesetter. + vt: &'v mut Vt<'t>, + /// Scratch arenas for building. + scratch: &'a Scratch<'a>, + /// The current document building state. + doc: Option>, + /// The current flow building state. + flow: FlowBuilder<'a>, + /// The current paragraph building state. + par: ParBuilder<'a>, + /// The current list building state. + list: ListBuilder<'a>, +} + +/// Temporary storage arenas for building. +#[derive(Default)] +struct Scratch<'a> { + /// An arena where intermediate style chains are stored. + styles: Arena>, + /// An arena where intermediate content resulting from show rules is stored. + content: Arena, +} + +impl<'a, 'v, 't> Builder<'a, 'v, 't> { + fn new(vt: &'v mut Vt<'t>, scratch: &'a Scratch<'a>, top: bool) -> Self { + Self { + vt, + scratch, + doc: top.then(DocBuilder::default), + flow: FlowBuilder::default(), + par: ParBuilder::default(), + list: ListBuilder::default(), + } + } + + fn accept( + &mut self, + mut content: &'a Content, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + if content.can::() && !content.is::() { + content = + self.scratch.content.alloc(EquationElem::new(content.clone()).pack()); + } + + if let Some(realized) = realize(self.vt, content, styles)? { + let stored = self.scratch.content.alloc(realized); + return self.accept(stored, styles); + } + + if let Some((elem, local)) = content.to_styled() { + return self.styled(elem, local, styles); + } + + if let Some(children) = content.to_sequence() { + for elem in children { + self.accept(elem, styles)?; + } + return Ok(()); + } + + if self.list.accept(content, styles) { + return Ok(()); + } + + self.interrupt_list()?; + + if self.list.accept(content, styles) { + return Ok(()); + } + + if self.par.accept(content, styles) { + return Ok(()); + } + + self.interrupt_par()?; + + if self.flow.accept(content, styles) { + return Ok(()); + } + + let keep = content + .to::() + .map_or(false, |pagebreak| !pagebreak.weak(styles)); + + self.interrupt_page(keep.then_some(styles))?; + + if let Some(doc) = &mut self.doc { + if doc.accept(content, styles) { + return Ok(()); + } + } + + if content.is::() { + bail!(content.span(), "pagebreaks are not allowed inside of containers"); + } else { + bail!(content.span(), "{} is not allowed here", content.func().name()); + } + } + + fn styled( + &mut self, + elem: &'a Content, + map: &'a Styles, + styles: StyleChain<'a>, + ) -> SourceResult<()> { + let stored = self.scratch.styles.alloc(styles); + let styles = stored.chain(map); + self.interrupt_style(map, None)?; + self.accept(elem, styles)?; + self.interrupt_style(map, Some(styles))?; + Ok(()) + } + + fn interrupt_style( + &mut self, + local: &Styles, + outer: Option>, + ) -> SourceResult<()> { + if let Some(Some(span)) = local.interruption::() { + if self.doc.is_none() { + bail!(span, "document set rules are not allowed inside of containers"); + } + if outer.is_none() + && (!self.flow.0.is_empty() + || !self.par.0.is_empty() + || !self.list.items.is_empty()) + { + bail!(span, "document set rules must appear before any content"); + } + } else if let Some(Some(span)) = local.interruption::() { + if self.doc.is_none() { + bail!(span, "page configuration is not allowed inside of containers"); + } + self.interrupt_page(outer)?; + } else if local.interruption::().is_some() + || local.interruption::().is_some() + { + self.interrupt_par()?; + } else if local.interruption::().is_some() + || local.interruption::().is_some() + || local.interruption::().is_some() + { + self.interrupt_list()?; + } + Ok(()) + } + + fn interrupt_list(&mut self) -> SourceResult<()> { + if !self.list.items.is_empty() { + let staged = mem::take(&mut self.list.staged); + let (list, styles) = mem::take(&mut self.list).finish(); + let stored = self.scratch.content.alloc(list); + self.accept(stored, styles)?; + for (content, styles) in staged { + self.accept(content, styles)?; + } + } + Ok(()) + } + + fn interrupt_par(&mut self) -> SourceResult<()> { + self.interrupt_list()?; + if !self.par.0.is_empty() { + let (par, styles) = mem::take(&mut self.par).finish(); + let stored = self.scratch.content.alloc(par); + self.accept(stored, styles)?; + } + + Ok(()) + } + + fn interrupt_page(&mut self, styles: Option>) -> SourceResult<()> { + self.interrupt_par()?; + let Some(doc) = &mut self.doc else { return Ok(()) }; + if !self.flow.0.is_empty() || (doc.keep_next && styles.is_some()) { + let (flow, shared) = mem::take(&mut self.flow).0.finish(); + let styles = if shared == StyleChain::default() { + styles.unwrap_or_default() + } else { + shared + }; + let page = PageElem::new(FlowElem::new(flow.to_vec()).pack()); + let stored = self.scratch.content.alloc(page.pack()); + self.accept(stored, styles)?; + } + Ok(()) + } +} + +/// Accepts pagebreaks and pages. +struct DocBuilder<'a> { + /// The page runs built so far. + pages: StyleVecBuilder<'a, Content>, + /// Whether to keep a following page even if it is empty. + keep_next: bool, + /// Whether the next page should be cleared to an even or odd number. + clear_next: Option, +} + +impl<'a> DocBuilder<'a> { + fn accept(&mut self, content: &Content, styles: StyleChain<'a>) -> bool { + if let Some(pagebreak) = content.to::() { + self.keep_next = !pagebreak.weak(styles); + self.clear_next = pagebreak.to(styles); + return true; + } + + if let Some(page) = content.to::() { + let elem = if let Some(clear_to) = self.clear_next.take() { + let mut page = page.clone(); + page.push_clear_to(Some(clear_to)); + page.pack() + } else { + content.clone() + }; + + self.pages.push(elem, styles); + self.keep_next = false; + return true; + } + + false + } +} + +impl Default for DocBuilder<'_> { + fn default() -> Self { + Self { + pages: StyleVecBuilder::new(), + keep_next: true, + clear_next: None, + } + } +} + +/// Accepts flow content. +#[derive(Default)] +struct FlowBuilder<'a>(BehavedBuilder<'a>, bool); + +impl<'a> FlowBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if content.is::() { + self.1 = true; + return true; + } + + let last_was_parbreak = self.1; + self.1 = false; + + if content.is::() + || content.is::() + || content.is::() + { + self.0.push(content.clone(), styles); + return true; + } + + if content.can::() || content.is::() { + let is_tight_list = if let Some(elem) = content.to::() { + elem.tight(styles) + } else if let Some(elem) = content.to::() { + elem.tight(styles) + } else if let Some(elem) = content.to::() { + elem.tight(styles) + } else { + false + }; + + if !last_was_parbreak && is_tight_list { + let leading = ParElem::leading_in(styles); + let spacing = VElem::list_attach(leading.into()); + self.0.push(spacing.pack(), styles); + } + + let (above, below) = if let Some(block) = content.to::() { + (block.above(styles), block.below(styles)) + } else { + (BlockElem::above_in(styles), BlockElem::below_in(styles)) + }; + + self.0.push(above.pack(), styles); + self.0.push(content.clone(), styles); + self.0.push(below.pack(), styles); + return true; + } + + false + } +} + +/// Accepts paragraph content. +#[derive(Default)] +struct ParBuilder<'a>(BehavedBuilder<'a>); + +impl<'a> ParBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if content.is::() { + if !self.0.is_basically_empty() { + self.0.push(content.clone(), styles); + return true; + } + } else if content.is::() + || content.is::() + || content.is::() + || content.is::() + || content.is::() + || content.to::().map_or(false, |elem| !elem.block(styles)) + || content.is::() + { + self.0.push(content.clone(), styles); + return true; + } + + false + } + + fn finish(self) -> (Content, StyleChain<'a>) { + let (children, shared) = self.0.finish(); + (ParElem::new(children.to_vec()).pack(), shared) + } +} + +/// Accepts list / enum items, spaces, paragraph breaks. +struct ListBuilder<'a> { + /// The list items collected so far. + items: StyleVecBuilder<'a, Content>, + /// Whether the list contains no paragraph breaks. + tight: bool, + /// Trailing content for which it is unclear whether it is part of the list. + staged: Vec<(&'a Content, StyleChain<'a>)>, +} + +impl<'a> ListBuilder<'a> { + fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> bool { + if !self.items.is_empty() + && (content.is::() || content.is::()) + { + self.staged.push((content, styles)); + return true; + } + + if (content.is::() + || content.is::() + || content.is::()) + && self + .items + .elems() + .next() + .map_or(true, |first| first.func() == content.func()) + { + self.items.push(content.clone(), styles); + self.tight &= self.staged.drain(..).all(|(t, _)| !t.is::()); + return true; + } + + false + } + + fn finish(self) -> (Content, StyleChain<'a>) { + let (items, shared) = self.items.finish(); + let item = items.items().next().unwrap(); + let output = if item.is::() { + ListElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::().unwrap(); + item.clone().with_body(item.body().styled_with_map(local.clone())) + }) + .collect::>(), + ) + .with_tight(self.tight) + .pack() + } else if item.is::() { + EnumElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::().unwrap(); + item.clone().with_body(item.body().styled_with_map(local.clone())) + }) + .collect::>(), + ) + .with_tight(self.tight) + .pack() + } else if item.is::() { + TermsElem::new( + items + .iter() + .map(|(item, local)| { + let item = item.to::().unwrap(); + item.clone() + .with_term(item.term().styled_with_map(local.clone())) + .with_description( + item.description().styled_with_map(local.clone()), + ) + }) + .collect::>(), + ) + .with_tight(self.tight) + .pack() + } else { + unreachable!() + }; + (output, shared) + } +} + +impl Default for ListBuilder<'_> { + fn default() -> Self { + Self { + items: StyleVecBuilder::default(), + tight: true, + staged: vec![], + } + } +} diff --git a/crates/typst-library/src/layout/pad.rs b/crates/typst-library/src/layout/pad.rs new file mode 100644 index 00000000..a3d5646b --- /dev/null +++ b/crates/typst-library/src/layout/pad.rs @@ -0,0 +1,125 @@ +use crate::prelude::*; + +/// Adds spacing around content. +/// +/// The spacing can be specified for each side individually, or for all sides at +/// once by specifying a positional argument. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// +/// #pad(x: 16pt, image("typing.jpg")) +/// _Typing speeds can be +/// measured in words per minute._ +/// ``` +/// +/// Display: Padding +/// Category: layout +#[element(Layout)] +pub struct PadElem { + /// The padding at the left side. + #[parse( + let all = args.named("rest")?.or(args.find()?); + let x = args.named("x")?.or(all); + let y = args.named("y")?.or(all); + args.named("left")?.or(x) + )] + pub left: Rel, + + /// The padding at the top side. + #[parse(args.named("top")?.or(y))] + pub top: Rel, + + /// The padding at the right side. + #[parse(args.named("right")?.or(x))] + pub right: Rel, + + /// The padding at the bottom side. + #[parse(args.named("bottom")?.or(y))] + pub bottom: Rel, + + /// The horizontal padding. Both `left` and `right` take precedence over + /// this. + #[external] + pub x: Rel, + + /// The vertical padding. Both `top` and `bottom` take precedence over this. + #[external] + pub y: Rel, + + /// The padding for all sides. All other parameters take precedence over + /// this. + #[external] + pub rest: Rel, + + /// The content to pad at the sides. + #[required] + pub body: Content, +} + +impl Layout for PadElem { + #[tracing::instrument(name = "PadElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let sides = Sides::new( + self.left(styles), + self.top(styles), + self.right(styles), + self.bottom(styles), + ); + + // Layout child into padded regions. + let mut backlog = vec![]; + let padding = sides.resolve(styles); + let pod = regions.map(&mut backlog, |size| shrink(size, padding)); + let mut fragment = self.body().layout(vt, styles, pod)?; + + for frame in &mut fragment { + // Apply the padding inversely such that the grown size padded + // yields the frame's size. + let padded = grow(frame.size(), padding); + let padding = padding.relative_to(padded); + let offset = Point::new(padding.left, padding.top); + + // Grow the frame and translate everything in the frame inwards. + frame.set_size(padded); + frame.translate(offset); + } + + Ok(fragment) + } +} + +/// Shrink a size by padding relative to the size itself. +fn shrink(size: Size, padding: Sides>) -> Size { + size - padding.relative_to(size).sum_by_axis() +} + +/// Grow a size by padding relative to the grown size. +/// This is the inverse operation to `shrink()`. +/// +/// For the horizontal axis the derivation looks as follows. +/// (Vertical axis is analogous.) +/// +/// Let w be the grown target width, +/// s be the given width, +/// l be the left padding, +/// r be the right padding, +/// p = l + r. +/// +/// We want that: w - l.resolve(w) - r.resolve(w) = s +/// +/// Thus: w - l.resolve(w) - r.resolve(w) = s +/// <=> w - p.resolve(w) = s +/// <=> w - p.rel * w - p.abs = s +/// <=> (1 - p.rel) * w = s + p.abs +/// <=> w = (s + p.abs) / (1 - p.rel) +fn grow(size: Size, padding: Sides>) -> Size { + size.zip(padding.sum_by_axis()) + .map(|(s, p)| (s + p.abs).safe_div(1.0 - p.rel.get())) +} diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs new file mode 100644 index 00000000..3b81f92c --- /dev/null +++ b/crates/typst-library/src/layout/page.rs @@ -0,0 +1,898 @@ +use std::ptr; +use std::str::FromStr; + +use super::{AlignElem, ColumnsElem}; +use crate::meta::{Counter, CounterKey, Numbering}; +use crate::prelude::*; +use crate::text::TextElem; + +/// Layouts its child onto one or multiple pages. +/// +/// Although this function is primarily used in set rules to affect page +/// properties, it can also be used to explicitly render its argument onto +/// a set of pages of its own. +/// +/// Pages can be set to use `{auto}` as their width or height. In this case, +/// the pages will grow to fit their content on the respective axis. +/// +/// ## Example { #example } +/// ```example +/// >>> #set page(margin: auto) +/// #set page("us-letter") +/// +/// There you go, US friends! +/// ``` +/// +/// Display: Page +/// Category: layout +#[element] +pub struct PageElem { + /// A standard paper size to set width and height. + #[external] + #[default(Paper::A4)] + pub paper: Paper, + + /// The width of the page. + /// + /// ```example + /// #set page( + /// width: 3cm, + /// margin: (x: 0cm), + /// ) + /// + /// #for i in range(3) { + /// box(square(width: 1cm)) + /// } + /// ``` + #[resolve] + #[parse( + let paper = args.named_or_find::("paper")?; + args.named("width")? + .or_else(|| paper.map(|paper| Smart::Custom(paper.width().into()))) + )] + #[default(Smart::Custom(Paper::A4.width().into()))] + pub width: Smart, + + /// The height of the page. + /// + /// If this is set to `{auto}`, page breaks can only be triggered manually + /// by inserting a [page break]($func/pagebreak). Most examples throughout + /// this documentation use `{auto}` for the height of the page to + /// dynamically grow and shrink to fit their content. + #[resolve] + #[parse( + args.named("height")? + .or_else(|| paper.map(|paper| Smart::Custom(paper.height().into()))) + )] + #[default(Smart::Custom(Paper::A4.height().into()))] + pub height: Smart, + + /// Whether the page is flipped into landscape orientation. + /// + /// ```example + /// #set page( + /// "us-business-card", + /// flipped: true, + /// fill: rgb("f2e5dd"), + /// ) + /// + /// #set align(bottom + end) + /// #text(14pt)[*Sam H. Richards*] \ + /// _Procurement Manager_ + /// + /// #set text(10pt) + /// 17 Main Street \ + /// New York, NY 10001 \ + /// +1 555 555 5555 + /// ``` + #[default(false)] + pub flipped: bool, + + /// The page's margins. + /// + /// - A single length: The same margin on all sides. + /// - `{auto}`: The margin is set to the default value for the page's size. + /// - A dictionary: With a dictionary, the margins can be set individually. + /// The dictionary can contain the following keys in order of precedence: + /// - `top`: The top margin. + /// - `right`: The right margin. + /// - `bottom`: The bottom margin. + /// - `left`: The left margin. + /// - `inside`: The margin at the inner side of the page (where the + /// [binding]($func/page.binding) is). + /// - `outside`: The margin at the outer side of the page (opposite to the + /// [binding]($func/page.binding)). + /// - `x`: The horizontal margins. + /// - `y`: The vertical margins. + /// - `rest`: The margins on all sides except those for which the + /// dictionary explicitly sets a size. + /// + /// The values for `left` and `right` are mutually exclusive with + /// the values for `inside` and `outside`. + /// + /// ```example + /// #set page( + /// width: 3cm, + /// height: 4cm, + /// margin: (x: 8pt, y: 4pt), + /// ) + /// + /// #rect( + /// width: 100%, + /// height: 100%, + /// fill: aqua, + /// ) + /// ``` + #[fold] + pub margin: Margin, + + /// On which side the pages will be bound. + /// + /// - `{auto}`: Equivalent to `left` if the [text direction]($func/text.dir) + /// is left-to-right and `right` if it is right-to-left. + /// - `left`: Bound on the left side. + /// - `right`: Bound on the right side. + /// + /// This affects the meaning of the `inside` and `outside` options for + /// margins. + pub binding: Smart, + + /// How many columns the page has. + /// + /// ```example:single + /// #set page(columns: 2, height: 4.8cm) + /// Climate change is one of the most + /// pressing issues of our time, with + /// the potential to devastate + /// communities, ecosystems, and + /// economies around the world. It's + /// clear that we need to take urgent + /// action to reduce our carbon + /// emissions and mitigate the impacts + /// of a rapidly changing climate. + /// ``` + #[default(NonZeroUsize::ONE)] + pub columns: NonZeroUsize, + + /// The page's background color. + /// + /// This instructs the printer to color the complete page with the given + /// color. If you are considering larger production runs, it may be more + /// environmentally friendly and cost-effective to source pre-dyed pages and + /// not set this property. + /// + /// ```example + /// #set page(fill: rgb("444352")) + /// #set text(fill: rgb("fdfdfd")) + /// *Dark mode enabled.* + /// ``` + pub fill: Option, + + /// How to [number]($func/numbering) the pages. + /// + /// If an explicit `footer` is given, the numbering is ignored. + /// + /// ```example + /// #set page( + /// height: 100pt, + /// margin: (top: 16pt, bottom: 24pt), + /// numbering: "1 / 1", + /// ) + /// + /// #lorem(48) + /// ``` + pub numbering: Option, + + /// The alignment of the page numbering. + /// + /// ```example + /// #set page( + /// margin: (top: 16pt, bottom: 24pt), + /// numbering: "1", + /// number-align: right, + /// ) + /// + /// #lorem(30) + /// ``` + #[default(Align::Center.into())] + pub number_align: Axes>, + + /// The page's header. Fills the top margin of each page. + /// + /// ```example + /// #set par(justify: true) + /// #set page( + /// margin: (top: 32pt, bottom: 20pt), + /// header: [ + /// #set text(8pt) + /// #smallcaps[Typst Academcy] + /// #h(1fr) _Exercise Sheet 3_ + /// ], + /// ) + /// + /// #lorem(19) + /// ``` + pub header: Option, + + /// The amount the header is raised into the top margin. + #[resolve] + #[default(Ratio::new(0.3).into())] + pub header_ascent: Rel, + + /// The page's footer. Fills the bottom margin of each page. + /// + /// For just a page number, the `numbering` property, typically suffices. If + /// you want to create a custom footer, but still display the page number, + /// you can directly access the [page counter]($func/counter). + /// + /// ```example + /// #set par(justify: true) + /// #set page( + /// height: 100pt, + /// margin: 20pt, + /// footer: [ + /// #set align(right) + /// #set text(8pt) + /// #counter(page).display( + /// "1 of I", + /// both: true, + /// ) + /// ] + /// ) + /// + /// #lorem(48) + /// ``` + pub footer: Option, + + /// The amount the footer is lowered into the bottom margin. + #[resolve] + #[default(Ratio::new(0.3).into())] + pub footer_descent: Rel, + + /// Content in the page's background. + /// + /// This content will be placed behind the page's body. It can be + /// used to place a background image or a watermark. + /// + /// ```example + /// #set page(background: rotate(24deg, + /// text(18pt, fill: rgb("FFCBC4"))[ + /// *CONFIDENTIAL* + /// ] + /// )) + /// + /// = Typst's secret plans + /// In the year 2023, we plan to take + /// over the world (of typesetting). + /// ``` + pub background: Option, + + /// Content in the page's foreground. + /// + /// This content will overlay the page's body. + /// + /// ```example + /// #set page(foreground: text(24pt)[🥸]) + /// + /// Reviewer 2 has marked our paper + /// "Weak Reject" because they did + /// not understand our approach... + /// ``` + pub foreground: Option, + + /// The contents of the page(s). + /// + /// Multiple pages will be created if the content does not fit on a single + /// page. A new page with the page properties prior to the function invocation + /// will be created after the body has been typeset. + #[required] + pub body: Content, + + /// Whether the page should be aligned to an even or odd page. + /// Not part of the public API for now. + #[internal] + pub clear_to: Option, +} + +impl PageElem { + /// A document can consist of multiple `PageElem`s, one per run of pages + /// with equal properties (not one per actual output page!). The `number` is + /// the physical page number of the first page of this run. It is mutated + /// while we post-process the pages in this function. This function returns + /// a fragment consisting of multiple frames, one per output page of this + /// page run. + #[tracing::instrument(skip_all)] + pub fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + mut number: NonZeroUsize, + ) -> SourceResult { + tracing::info!("Page layout"); + + // When one of the lengths is infinite the page fits its content along + // that axis. + let width = self.width(styles).unwrap_or(Abs::inf()); + let height = self.height(styles).unwrap_or(Abs::inf()); + let mut size = Size::new(width, height); + if self.flipped(styles) { + std::mem::swap(&mut size.x, &mut size.y); + } + + let mut min = width.min(height); + if !min.is_finite() { + min = Paper::A4.width(); + } + + // Determine the margins. + let default = Rel::::from(0.1190 * min); + let margin = self.margin(styles); + let two_sided = margin.two_sided.unwrap_or(false); + let margin = margin + .sides + .map(|side| side.and_then(Smart::as_custom).unwrap_or(default)) + .resolve(styles) + .relative_to(size); + + // Determine the binding. + let binding = + self.binding(styles) + .unwrap_or_else(|| match TextElem::dir_in(styles) { + Dir::LTR => Binding::Left, + _ => Binding::Right, + }); + + // Realize columns. + let mut child = self.body(); + let columns = self.columns(styles); + if columns.get() > 1 { + child = ColumnsElem::new(child).with_count(columns).pack(); + } + + let area = size - margin.sum_by_axis(); + let mut regions = Regions::repeat(area, area.map(Abs::is_finite)); + regions.root = true; + + // Layout the child. + let mut frames = child.layout(vt, styles, regions)?.into_frames(); + + // Align the child to the pagebreak's parity. + if self.clear_to(styles).is_some_and(|p| !p.matches(number.get())) { + let size = area.map(Abs::is_finite).select(area, Size::zero()); + frames.insert(0, Frame::new(size)); + } + + let fill = self.fill(styles); + let foreground = self.foreground(styles); + let background = self.background(styles); + let header = self.header(styles); + let header_ascent = self.header_ascent(styles); + let footer = self.footer(styles).or_else(|| { + self.numbering(styles).map(|numbering| { + let both = match &numbering { + Numbering::Pattern(pattern) => pattern.pieces() >= 2, + Numbering::Func(_) => true, + }; + Counter::new(CounterKey::Page) + .display(Some(numbering), both) + .aligned(self.number_align(styles)) + }) + }); + let footer_descent = self.footer_descent(styles); + + let numbering_meta = FrameItem::Meta( + Meta::PageNumbering(self.numbering(styles).into_value()), + Size::zero(), + ); + + // Post-process pages. + for frame in frames.iter_mut() { + tracing::info!("Layouting page #{number}"); + + // The padded width of the page's content without margins. + let pw = frame.width(); + + // If two sided, left becomes inside and right becomes outside. + // Thus, for left-bound pages, we want to swap on even pages and + // for right-bound pages, we want to swap on odd pages. + let mut margin = margin; + if two_sided && binding.swap(number) { + std::mem::swap(&mut margin.left, &mut margin.right); + } + + // Realize margins. + frame.set_size(frame.size() + margin.sum_by_axis()); + frame.translate(Point::new(margin.left, margin.top)); + frame.push(Point::zero(), numbering_meta.clone()); + + // The page size with margins. + let size = frame.size(); + + // Realize overlays. + for (name, marginal) in [ + ("header", &header), + ("footer", &footer), + ("background", &background), + ("foreground", &foreground), + ] { + tracing::info!("Layouting {name}"); + + let Some(content) = marginal else { continue }; + + let (pos, area, align); + if ptr::eq(marginal, &header) { + let ascent = header_ascent.relative_to(margin.top); + pos = Point::with_x(margin.left); + area = Size::new(pw, margin.top - ascent); + align = Align::Bottom.into(); + } else if ptr::eq(marginal, &footer) { + let descent = footer_descent.relative_to(margin.bottom); + pos = Point::new(margin.left, size.y - margin.bottom + descent); + area = Size::new(pw, margin.bottom - descent); + align = Align::Top.into(); + } else { + pos = Point::zero(); + area = size; + align = Align::CENTER_HORIZON.into(); + }; + + let pod = Regions::one(area, Axes::splat(true)); + let sub = content + .clone() + .styled(AlignElem::set_alignment(align)) + .layout(vt, styles, pod)? + .into_frame(); + + if ptr::eq(marginal, &header) || ptr::eq(marginal, &background) { + frame.prepend_frame(pos, sub); + } else { + frame.push_frame(pos, sub); + } + } + + if let Some(fill) = &fill { + frame.fill(fill.clone()); + } + + number = number.saturating_add(1); + } + + Ok(Fragment::frames(frames)) + } +} + +/// Specification of the page's margins. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Margin { + /// The margins for each side. + pub sides: Sides>>>, + /// Whether to swap `left` and `right` to make them `inside` and `outside` + /// (when to swap depends on the binding). + pub two_sided: Option, +} + +impl Margin { + /// Create an instance with four equal components. + pub fn splat(value: Option>>) -> Self { + Self { sides: Sides::splat(value), two_sided: None } + } +} + +impl Fold for Margin { + type Output = Margin; + + fn fold(self, outer: Self::Output) -> Self::Output { + let sides = + self.sides + .zip(outer.sides) + .map(|(inner, outer)| match (inner, outer) { + (Some(value), Some(outer)) => Some(value.fold(outer)), + _ => inner.or(outer), + }); + let two_sided = self.two_sided.or(outer.two_sided); + Margin { sides, two_sided } + } +} + +cast! { + Margin, + self => { + let mut dict = Dict::new(); + let mut handle = |key: &str, component: Value| { + let value = component.into_value(); + if value != Value::None { + dict.insert(key.into(), value); + } + }; + + handle("top", self.sides.top.into_value()); + handle("bottom", self.sides.bottom.into_value()); + if self.two_sided.unwrap_or(false) { + handle("inside", self.sides.left.into_value()); + handle("outside", self.sides.right.into_value()); + } else { + handle("left", self.sides.left.into_value()); + handle("right", self.sides.right.into_value()); + } + + Value::Dict(dict) + }, + _: AutoValue => Self::splat(Some(Smart::Auto)), + v: Rel => Self::splat(Some(Smart::Custom(v))), + mut dict: Dict => { + let mut take = |key| dict.take(key).ok().map(Value::cast).transpose(); + + let rest = take("rest")?; + let x = take("x")?.or(rest); + let y = take("y")?.or(rest); + let top = take("top")?.or(y); + let bottom = take("bottom")?.or(y); + let outside = take("outside")?; + let inside = take("inside")?; + let left = take("left")?; + let right = take("right")?; + + let implicitly_two_sided = outside.is_some() || inside.is_some(); + let implicitly_not_two_sided = left.is_some() || right.is_some(); + if implicitly_two_sided && implicitly_not_two_sided { + bail!("`inside` and `outside` are mutually exclusive with `left` and `right`"); + } + + // - If 'implicitly_two_sided' is false here, then + // 'implicitly_not_two_sided' will be guaranteed to be true + // due to the previous two 'if' conditions. + // - If both are false, this means that this margin change does not + // affect lateral margins, and thus shouldn't make a difference on + // the 'two_sided' attribute of this margin. + let two_sided = (implicitly_two_sided || implicitly_not_two_sided) + .then_some(implicitly_two_sided); + + dict.finish(&[ + "left", "top", "right", "bottom", "outside", "inside", "x", "y", "rest", + ])?; + + Margin { + sides: Sides { + left: inside.or(left).or(x), + top, + right: outside.or(right).or(x), + bottom, + }, + two_sided, + } + } +} + +/// Specification of the page's binding. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Binding { + /// Bound on the left, as customary in LTR languages. + Left, + /// Bound on the right, as customary in RTL languages. + Right, +} + +impl Binding { + /// Whether to swap left and right margin for the page with this number. + fn swap(self, number: NonZeroUsize) -> bool { + match self { + // Left-bound must swap on even pages + // (because it is correct on the first page). + Self::Left => number.get() % 2 == 0, + // Right-bound must swap on odd pages + // (because it is wrong on the first page). + Self::Right => number.get() % 2 == 1, + } + } +} + +cast! { + Binding, + self => match self { + Self::Left => GenAlign::Specific(Align::Left).into_value(), + Self::Right => GenAlign::Specific(Align::Right).into_value(), + }, + v: GenAlign => match v { + GenAlign::Specific(Align::Left) => Self::Left, + GenAlign::Specific(Align::Right) => Self::Right, + _ => bail!("must be `left` or `right`"), + }, +} + +/// A header, footer, foreground or background definition. +#[derive(Debug, Clone, Hash)] +pub enum Marginal { + /// Bare content. + Content(Content), + /// A closure mapping from a page number to content. + Func(Func), +} + +impl Marginal { + /// Resolve the marginal based on the page number. + pub fn resolve(&self, vt: &mut Vt, page: usize) -> SourceResult { + Ok(match self { + Self::Content(content) => content.clone(), + Self::Func(func) => func.call_vt(vt, [page])?.display(), + }) + } +} + +cast! { + Marginal, + self => match self { + Self::Content(v) => v.into_value(), + Self::Func(v) => v.into_value(), + }, + v: Content => Self::Content(v), + v: Func => Self::Func(v), +} + +/// A manual page break. +/// +/// Must not be used inside any containers. +/// +/// ## Example { #example } +/// ```example +/// The next page contains +/// more details on compound theory. +/// #pagebreak() +/// +/// == Compound Theory +/// In 1984, the first ... +/// ``` +/// +/// Display: Page Break +/// Category: layout +#[element] +pub struct PagebreakElem { + /// If `{true}`, the page break is skipped if the current page is already + /// empty. + #[default(false)] + pub weak: bool, + + /// If given, ensures that the next page will be an even/odd page, with an + /// empty page in between if necessary. + /// + /// ```example + /// #set page(height: 30pt) + /// + /// First. + /// #pagebreak(to: "odd") + /// Third. + /// ``` + pub to: Option, +} + +/// Whether something should be even or odd. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Parity { + /// Next page will be an even page. + Even, + /// Next page will be an odd page. + Odd, +} + +impl Parity { + /// Whether the given number matches the parity. + fn matches(self, number: usize) -> bool { + match self { + Self::Even => number % 2 == 0, + Self::Odd => number % 2 == 1, + } + } +} + +/// Specification of a paper. +#[derive(Debug, Copy, Clone, Hash)] +pub struct Paper { + /// The name of the paper. + name: &'static str, + /// The width of the paper in millimeters. + width: Scalar, + /// The height of the paper in millimeters. + height: Scalar, +} + +impl Paper { + /// The width of the paper. + pub fn width(self) -> Abs { + Abs::mm(self.width.0) + } + + /// The height of the paper. + pub fn height(self) -> Abs { + Abs::mm(self.height.0) + } +} + +/// Defines paper constants and a paper parsing implementation. +macro_rules! papers { + ($(($var:ident: $width:expr, $height: expr, $name:literal))*) => { + /// Predefined papers. + /// + /// Each paper is parsable from its name in kebab-case. + impl Paper { + $(pub const $var: Self = Self { + name: $name, + width: Scalar($width), + height: Scalar($height), + };)* + } + + impl FromStr for Paper { + type Err = &'static str; + + fn from_str(name: &str) -> Result { + match name.to_lowercase().as_str() { + $($name => Ok(Self::$var),)* + _ => Err("unknown paper size"), + } + } + } + + cast! { + Paper, + self => self.name.into_value(), + $( + /// Produces a paper of the respective size. + $name => Self::$var, + )* + } + }; +} + +// All paper sizes in mm. +// +// Resources: +// - https://papersizes.io/ +// - https://en.wikipedia.org/wiki/Paper_size +// - https://www.theedkins.co.uk/jo/units/oldunits/print.htm +// - https://vintagepaper.co/blogs/news/traditional-paper-sizes +papers! { + // ---------------------------------------------------------------------- // + // ISO 216 A Series + (A0: 841.0, 1189.0, "a0") + (A1: 594.0, 841.0, "a1") + (A2: 420.0, 594.0, "a2") + (A3: 297.0, 420.0, "a3") + (A4: 210.0, 297.0, "a4") + (A5: 148.0, 210.0, "a5") + (A6: 105.0, 148.0, "a6") + (A7: 74.0, 105.0, "a7") + (A8: 52.0, 74.0, "a8") + (A9: 37.0, 52.0, "a9") + (A10: 26.0, 37.0, "a10") + (A11: 18.0, 26.0, "a11") + + // ISO 216 B Series + (ISO_B1: 707.0, 1000.0, "iso-b1") + (ISO_B2: 500.0, 707.0, "iso-b2") + (ISO_B3: 353.0, 500.0, "iso-b3") + (ISO_B4: 250.0, 353.0, "iso-b4") + (ISO_B5: 176.0, 250.0, "iso-b5") + (ISO_B6: 125.0, 176.0, "iso-b6") + (ISO_B7: 88.0, 125.0, "iso-b7") + (ISO_B8: 62.0, 88.0, "iso-b8") + + // ISO 216 C Series + (ISO_C3: 324.0, 458.0, "iso-c3") + (ISO_C4: 229.0, 324.0, "iso-c4") + (ISO_C5: 162.0, 229.0, "iso-c5") + (ISO_C6: 114.0, 162.0, "iso-c6") + (ISO_C7: 81.0, 114.0, "iso-c7") + (ISO_C8: 57.0, 81.0, "iso-c8") + + // DIN D Series (extension to ISO) + (DIN_D3: 272.0, 385.0, "din-d3") + (DIN_D4: 192.0, 272.0, "din-d4") + (DIN_D5: 136.0, 192.0, "din-d5") + (DIN_D6: 96.0, 136.0, "din-d6") + (DIN_D7: 68.0, 96.0, "din-d7") + (DIN_D8: 48.0, 68.0, "din-d8") + + // SIS (used in academia) + (SIS_G5: 169.0, 239.0, "sis-g5") + (SIS_E5: 115.0, 220.0, "sis-e5") + + // ANSI Extensions + (ANSI_A: 216.0, 279.0, "ansi-a") + (ANSI_B: 279.0, 432.0, "ansi-b") + (ANSI_C: 432.0, 559.0, "ansi-c") + (ANSI_D: 559.0, 864.0, "ansi-d") + (ANSI_E: 864.0, 1118.0, "ansi-e") + + // ANSI Architectural Paper + (ARCH_A: 229.0, 305.0, "arch-a") + (ARCH_B: 305.0, 457.0, "arch-b") + (ARCH_C: 457.0, 610.0, "arch-c") + (ARCH_D: 610.0, 914.0, "arch-d") + (ARCH_E1: 762.0, 1067.0, "arch-e1") + (ARCH_E: 914.0, 1219.0, "arch-e") + + // JIS B Series + (JIS_B0: 1030.0, 1456.0, "jis-b0") + (JIS_B1: 728.0, 1030.0, "jis-b1") + (JIS_B2: 515.0, 728.0, "jis-b2") + (JIS_B3: 364.0, 515.0, "jis-b3") + (JIS_B4: 257.0, 364.0, "jis-b4") + (JIS_B5: 182.0, 257.0, "jis-b5") + (JIS_B6: 128.0, 182.0, "jis-b6") + (JIS_B7: 91.0, 128.0, "jis-b7") + (JIS_B8: 64.0, 91.0, "jis-b8") + (JIS_B9: 45.0, 64.0, "jis-b9") + (JIS_B10: 32.0, 45.0, "jis-b10") + (JIS_B11: 22.0, 32.0, "jis-b11") + + // SAC D Series + (SAC_D0: 764.0, 1064.0, "sac-d0") + (SAC_D1: 532.0, 760.0, "sac-d1") + (SAC_D2: 380.0, 528.0, "sac-d2") + (SAC_D3: 264.0, 376.0, "sac-d3") + (SAC_D4: 188.0, 260.0, "sac-d4") + (SAC_D5: 130.0, 184.0, "sac-d5") + (SAC_D6: 92.0, 126.0, "sac-d6") + + // ISO 7810 ID + (ISO_ID_1: 85.6, 53.98, "iso-id-1") + (ISO_ID_2: 74.0, 105.0, "iso-id-2") + (ISO_ID_3: 88.0, 125.0, "iso-id-3") + + // ---------------------------------------------------------------------- // + // Asia + (ASIA_F4: 210.0, 330.0, "asia-f4") + + // Japan + (JP_SHIROKU_BAN_4: 264.0, 379.0, "jp-shiroku-ban-4") + (JP_SHIROKU_BAN_5: 189.0, 262.0, "jp-shiroku-ban-5") + (JP_SHIROKU_BAN_6: 127.0, 188.0, "jp-shiroku-ban-6") + (JP_KIKU_4: 227.0, 306.0, "jp-kiku-4") + (JP_KIKU_5: 151.0, 227.0, "jp-kiku-5") + (JP_BUSINESS_CARD: 91.0, 55.0, "jp-business-card") + + // China + (CN_BUSINESS_CARD: 90.0, 54.0, "cn-business-card") + + // Europe + (EU_BUSINESS_CARD: 85.0, 55.0, "eu-business-card") + + // French Traditional (AFNOR) + (FR_TELLIERE: 340.0, 440.0, "fr-tellière") + (FR_COURONNE_ECRITURE: 360.0, 460.0, "fr-couronne-écriture") + (FR_COURONNE_EDITION: 370.0, 470.0, "fr-couronne-édition") + (FR_RAISIN: 500.0, 650.0, "fr-raisin") + (FR_CARRE: 450.0, 560.0, "fr-carré") + (FR_JESUS: 560.0, 760.0, "fr-jésus") + + // United Kingdom Imperial + (UK_BRIEF: 406.4, 342.9, "uk-brief") + (UK_DRAFT: 254.0, 406.4, "uk-draft") + (UK_FOOLSCAP: 203.2, 330.2, "uk-foolscap") + (UK_QUARTO: 203.2, 254.0, "uk-quarto") + (UK_CROWN: 508.0, 381.0, "uk-crown") + (UK_BOOK_A: 111.0, 178.0, "uk-book-a") + (UK_BOOK_B: 129.0, 198.0, "uk-book-b") + + // Unites States + (US_LETTER: 215.9, 279.4, "us-letter") + (US_LEGAL: 215.9, 355.6, "us-legal") + (US_TABLOID: 279.4, 431.8, "us-tabloid") + (US_EXECUTIVE: 84.15, 266.7, "us-executive") + (US_FOOLSCAP_FOLIO: 215.9, 342.9, "us-foolscap-folio") + (US_STATEMENT: 139.7, 215.9, "us-statement") + (US_LEDGER: 431.8, 279.4, "us-ledger") + (US_OFICIO: 215.9, 340.36, "us-oficio") + (US_GOV_LETTER: 203.2, 266.7, "us-gov-letter") + (US_GOV_LEGAL: 215.9, 330.2, "us-gov-legal") + (US_BUSINESS_CARD: 88.9, 50.8, "us-business-card") + (US_DIGEST: 139.7, 215.9, "us-digest") + (US_TRADE: 152.4, 228.6, "us-trade") + + // ---------------------------------------------------------------------- // + // Other + (NEWSPAPER_COMPACT: 280.0, 430.0, "newspaper-compact") + (NEWSPAPER_BERLINER: 315.0, 470.0, "newspaper-berliner") + (NEWSPAPER_BROADSHEET: 381.0, 578.0, "newspaper-broadsheet") + (PRESENTATION_16_9: 297.0, 167.0625, "presentation-16-9") + (PRESENTATION_4_3: 280.0, 210.0, "presentation-4-3") +} diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs new file mode 100644 index 00000000..6b914e80 --- /dev/null +++ b/crates/typst-library/src/layout/par.rs @@ -0,0 +1,1566 @@ +use icu_properties::{maps::CodePointMapData, LineBreak}; +use icu_provider::AsDeserializingBufferProvider; +use icu_provider_adapters::fork::ForkByKeyProvider; +use icu_provider_blob::BlobDataProvider; +use icu_segmenter::{LineBreakIteratorUtf8, LineSegmenter}; +use once_cell::sync::Lazy; +use typst::eval::Tracer; +use typst::model::DelayedErrors; +use unicode_bidi::{BidiInfo, Level as BidiLevel}; +use unicode_script::{Script, UnicodeScript}; + +use super::{BoxElem, HElem, Sizing, Spacing}; +use crate::layout::AlignElem; +use crate::math::EquationElem; +use crate::prelude::*; +use crate::text::{ + is_gb_style, shape, LinebreakElem, Quoter, Quotes, ShapedText, SmartQuoteElem, + SpaceElem, TextElem, +}; + +/// Arranges text, spacing and inline-level elements into a paragraph. +/// +/// Although this function is primarily used in set rules to affect paragraph +/// properties, it can also be used to explicitly render its argument onto a +/// paragraph of its own. +/// +/// ## Example { #example } +/// ```example +/// #show par: set block(spacing: 0.65em) +/// #set par( +/// first-line-indent: 1em, +/// justify: true, +/// ) +/// +/// We proceed by contradiction. +/// Suppose that there exists a set +/// of positive integers $a$, $b$, and +/// $c$ that satisfies the equation +/// $a^n + b^n = c^n$ for some +/// integer value of $n > 2$. +/// +/// Without loss of generality, +/// let $a$ be the smallest of the +/// three integers. Then, we ... +/// ``` +/// +/// Display: Paragraph +/// Category: layout +#[element(Construct)] +pub struct ParElem { + /// The spacing between lines. + #[resolve] + #[default(Em::new(0.65).into())] + pub leading: Length, + + /// Whether to justify text in its line. + /// + /// Hyphenation will be enabled for justified paragraphs if the [text + /// property hyphenate]($func/text.hyphenate) is set to `{auto}` and the + /// current language is known. + /// + /// Note that the current [alignment]($func/align) still has an effect on + /// the placement of the last line except if it ends with a [justified line + /// break]($func/linebreak.justify). + #[default(false)] + pub justify: bool, + + /// How to determine line breaks. + /// + /// When this property is set to `{auto}`, its default value, optimized line + /// breaks will be used for justified paragraphs. Enabling optimized line + /// breaks for ragged paragraphs may also be worthwhile to improve the + /// appearance of the text. + /// + /// ```example + /// #set page(width: 190pt) + /// #set par(linebreaks: "simple") + /// Some texts are frustratingly + /// challenging to break in a + /// visually pleasing way. This + /// very aesthetic example is one + /// of them. + /// + /// #set par(linebreaks: "optimized") + /// Some texts are frustratingly + /// challenging to break in a + /// visually pleasing way. This + /// very aesthetic example is one + /// of them. + /// ``` + #[default] + pub linebreaks: Smart, + + /// The indent the first line of a paragraph should have. + /// + /// Only the first line of a consecutive paragraph will be indented (not + /// the first one in a block or on the page). + /// + /// By typographic convention, paragraph breaks are indicated either by some + /// space between paragraphs or by indented first lines. Consider reducing + /// the [paragraph spacing]($func/block.spacing) to the [`leading`] when + /// using this property (e.g. using + /// `[#show par: set block(spacing: 0.65em)]`). + pub first_line_indent: Length, + + /// The indent all but the first line of a paragraph should have. + #[resolve] + pub hanging_indent: Length, + + /// The contents of the paragraph. + #[external] + #[required] + pub body: Content, + + /// The paragraph's children. + #[internal] + #[variadic] + pub children: Vec, +} + +impl Construct for ParElem { + fn construct(_: &mut Vm, args: &mut Args) -> SourceResult { + // The paragraph constructor is special: It doesn't create a paragraph + // element. Instead, it just ensures that the passed content lives in a + // separate paragraph and styles it. + let styles = Self::set(args)?; + let body = args.expect::("body")?; + Ok(Content::sequence([ + ParbreakElem::new().pack(), + body.styled_with_map(styles), + ParbreakElem::new().pack(), + ])) + } +} + +impl ParElem { + /// Layout the paragraph into a collection of lines. + #[tracing::instrument(name = "ParElement::layout", skip_all)] + pub fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, + ) -> SourceResult { + #[comemo::memoize] + #[allow(clippy::too_many_arguments)] + fn cached( + par: &ParElem, + world: Tracked, + introspector: Tracked, + locator: Tracked, + delayed: TrackedMut, + tracer: TrackedMut, + styles: StyleChain, + consecutive: bool, + region: Size, + expand: bool, + ) -> SourceResult { + let mut locator = Locator::chained(locator); + let mut vt = Vt { + world, + introspector, + locator: &mut locator, + delayed, + tracer, + }; + let children = par.children(); + + // Collect all text into one string for BiDi analysis. + let (text, segments, spans) = collect(&children, &styles, consecutive)?; + + // Perform BiDi analysis and then prepare paragraph layout by building a + // representation on which we can do line breaking without layouting + // each and every line from scratch. + let p = prepare(&mut vt, &children, &text, segments, spans, styles, region)?; + + // Break the paragraph into lines. + let lines = linebreak(&vt, &p, region.x - p.hang); + + // Stack the lines into one frame per region. + finalize(&mut vt, &p, &lines, region, expand) + } + + let fragment = cached( + self, + vt.world, + vt.introspector, + vt.locator.track(), + TrackedMut::reborrow_mut(&mut vt.delayed), + TrackedMut::reborrow_mut(&mut vt.tracer), + styles, + consecutive, + region, + expand, + )?; + + vt.locator.visit_frames(&fragment); + Ok(fragment) + } +} + +/// How to determine line breaks in a paragraph. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Linebreaks { + /// Determine the line breaks in a simple first-fit style. + Simple, + /// Optimize the line breaks for the whole paragraph. + /// + /// Typst will try to produce more evenly filled lines of text by + /// considering the whole paragraph when calculating line breaks. + Optimized, +} + +/// A paragraph break. +/// +/// This starts a new paragraph. Especially useful when used within code like +/// [for loops]($scripting/#loops). Multiple consecutive +/// paragraph breaks collapse into a single one. +/// +/// ## Example { #example } +/// ```example +/// #for i in range(3) { +/// [Blind text #i: ] +/// lorem(5) +/// parbreak() +/// } +/// ``` +/// +/// ## Syntax { #syntax } +/// Instead of calling this function, you can insert a blank line into your +/// markup to create a paragraph break. +/// +/// Display: Paragraph Break +/// Category: layout +#[element(Unlabellable)] +pub struct ParbreakElem {} + +impl Unlabellable for ParbreakElem {} + +/// Range of a substring of text. +type Range = std::ops::Range; + +// The characters by which spacing, inline content and pins are replaced in the +// paragraph's full text. +const SPACING_REPLACE: char = ' '; // Space +const OBJ_REPLACE: char = '\u{FFFC}'; // Object Replacement Character + +/// A paragraph representation in which children are already layouted and text +/// is already preshaped. +/// +/// In many cases, we can directly reuse these results when constructing a line. +/// Only when a line break falls onto a text index that is not safe-to-break per +/// rustybuzz, we have to reshape that portion. +struct Preparation<'a> { + /// Bidirectional text embedding levels for the paragraph. + bidi: BidiInfo<'a>, + /// Text runs, spacing and layouted elements. + items: Vec>, + /// The span mapper. + spans: SpanMapper, + /// The styles shared by all children. + styles: StyleChain<'a>, + /// Whether to hyphenate if it's the same for all children. + hyphenate: Option, + /// The text language if it's the same for all children. + lang: Option, + /// The paragraph's resolved alignment. + align: Align, + /// Whether to justify the paragraph. + justify: bool, + /// The paragraph's hanging indent. + hang: Abs, +} + +impl<'a> Preparation<'a> { + /// Find the item that contains the given `text_offset`. + fn find(&self, text_offset: usize) -> Option<&Item<'a>> { + let mut cursor = 0; + for item in &self.items { + let end = cursor + item.len(); + if (cursor..end).contains(&text_offset) { + return Some(item); + } + cursor = end; + } + None + } + + /// Return the items that intersect the given `text_range`. + /// + /// Returns the expanded range around the items and the items. + fn slice(&self, text_range: Range) -> (Range, &[Item<'a>]) { + let mut cursor = 0; + let mut start = 0; + let mut end = 0; + let mut expanded = text_range.clone(); + + for (i, item) in self.items.iter().enumerate() { + if cursor <= text_range.start { + start = i; + expanded.start = cursor; + } + + let len = item.len(); + if cursor < text_range.end || cursor + len <= text_range.end { + end = i + 1; + expanded.end = cursor + len; + } else { + break; + } + + cursor += len; + } + + (expanded, &self.items[start..end]) + } +} + +/// A segment of one or multiple collapsed children. +#[derive(Debug, Copy, Clone)] +enum Segment<'a> { + /// One or multiple collapsed text or text-equivalent children. Stores how + /// long the segment is (in bytes of the full text string). + Text(usize), + /// Horizontal spacing between other segments. + Spacing(Spacing), + /// A mathematical equation. + Equation(&'a EquationElem), + /// A box with arbitrary content. + Box(&'a BoxElem, bool), + /// Metadata. + Meta, +} + +impl Segment<'_> { + /// The text length of the item. + fn len(&self) -> usize { + match *self { + Self::Text(len) => len, + Self::Spacing(_) => SPACING_REPLACE.len_utf8(), + Self::Box(_, true) => SPACING_REPLACE.len_utf8(), + Self::Equation(_) | Self::Box(_, _) => OBJ_REPLACE.len_utf8(), + Self::Meta => 0, + } + } +} + +/// A prepared item in a paragraph layout. +#[derive(Debug)] +enum Item<'a> { + /// A shaped text run with consistent style and direction. + Text(ShapedText<'a>), + /// Absolute spacing between other items. + Absolute(Abs), + /// Fractional spacing between other items. + Fractional(Fr, Option<(&'a BoxElem, StyleChain<'a>)>), + /// Layouted inline-level content. + Frame(Frame), + /// Metadata. + Meta(Frame), +} + +impl<'a> Item<'a> { + /// If this a text item, return it. + fn text(&self) -> Option<&ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + fn text_mut(&mut self) -> Option<&mut ShapedText<'a>> { + match self { + Self::Text(shaped) => Some(shaped), + _ => None, + } + } + + /// The text length of the item. + fn len(&self) -> usize { + match self { + Self::Text(shaped) => shaped.text.len(), + Self::Absolute(_) | Self::Fractional(_, _) => SPACING_REPLACE.len_utf8(), + Self::Frame(_) => OBJ_REPLACE.len_utf8(), + Self::Meta(_) => 0, + } + } + + /// The natural layouted width of the item. + fn width(&self) -> Abs { + match self { + Self::Text(shaped) => shaped.width, + Self::Absolute(v) => *v, + Self::Frame(frame) => frame.width(), + Self::Fractional(_, _) | Self::Meta(_) => Abs::zero(), + } + } +} + +/// Maps byte offsets back to spans. +#[derive(Default)] +pub struct SpanMapper(Vec<(usize, Span)>); + +impl SpanMapper { + /// Create a new span mapper. + pub fn new() -> Self { + Self::default() + } + + /// Push a span for a segment with the given length. + pub fn push(&mut self, len: usize, span: Span) { + self.0.push((len, span)); + } + + /// Determine the span at the given byte offset. + /// + /// May return a detached span. + pub fn span_at(&self, offset: usize) -> (Span, u16) { + let mut cursor = 0; + for &(len, span) in &self.0 { + if (cursor..=cursor + len).contains(&offset) { + return (span, u16::try_from(offset - cursor).unwrap_or(0)); + } + cursor += len; + } + (Span::detached(), 0) + } +} + +/// A layouted line, consisting of a sequence of layouted paragraph items that +/// are mostly borrowed from the preparation phase. This type enables you to +/// measure the size of a line in a range before committing to building the +/// line's frame. +/// +/// At most two paragraph items must be created individually for this line: The +/// first and last one since they may be broken apart by the start or end of the +/// line, respectively. But even those can partially reuse previous results when +/// the break index is safe-to-break per rustybuzz. +struct Line<'a> { + /// Bidi information about the paragraph. + bidi: &'a BidiInfo<'a>, + /// The trimmed range the line spans in the paragraph. + trimmed: Range, + /// The untrimmed end where the line ends. + end: usize, + /// A reshaped text item if the line sliced up a text item at the start. + first: Option>, + /// Inner items which don't need to be reprocessed. + inner: &'a [Item<'a>], + /// A reshaped text item if the line sliced up a text item at the end. If + /// there is only one text item, this takes precedence over `first`. + last: Option>, + /// The width of the line. + width: Abs, + /// Whether the line should be justified. + justify: bool, + /// Whether the line ends with a hyphen or dash, either naturally or through + /// hyphenation. + dash: bool, +} + +impl<'a> Line<'a> { + /// Iterate over the line's items. + fn items(&self) -> impl Iterator> { + self.first.iter().chain(self.inner).chain(&self.last) + } + + /// Return items that intersect the given `text_range`. + fn slice(&self, text_range: Range) -> impl Iterator> { + let mut cursor = self.trimmed.start; + let mut start = 0; + let mut end = 0; + + for (i, item) in self.items().enumerate() { + if cursor <= text_range.start { + start = i; + } + + let len = item.len(); + if cursor < text_range.end || cursor + len <= text_range.end { + end = i + 1; + } else { + break; + } + + cursor += len; + } + + self.items().skip(start).take(end - start) + } + + /// How many glyphs are in the text where we can insert additional + /// space when encountering underfull lines. + fn justifiables(&self) -> usize { + let mut count = 0; + for shaped in self.items().filter_map(Item::text) { + count += shaped.justifiables(); + } + // CJK character at line end should not be adjusted. + if self + .items() + .last() + .and_then(Item::text) + .map(|s| s.cjk_justifiable_at_last()) + .unwrap_or(false) + { + count -= 1; + } + + count + } + + /// How much can the line stretch + fn stretchability(&self) -> Abs { + self.items().filter_map(Item::text).map(|s| s.stretchability()).sum() + } + + /// How much can the line shrink + fn shrinkability(&self) -> Abs { + self.items().filter_map(Item::text).map(|s| s.shrinkability()).sum() + } + + /// The sum of fractions in the line. + fn fr(&self) -> Fr { + self.items() + .filter_map(|item| match item { + Item::Fractional(fr, _) => Some(*fr), + _ => None, + }) + .sum() + } +} + +/// Collect all text of the paragraph into one string. This also performs +/// string-level preprocessing like case transformations. +#[allow(clippy::type_complexity)] +fn collect<'a>( + children: &'a [Content], + styles: &'a StyleChain<'a>, + consecutive: bool, +) -> SourceResult<(String, Vec<(Segment<'a>, StyleChain<'a>)>, SpanMapper)> { + let mut full = String::new(); + let mut quoter = Quoter::new(); + let mut segments = vec![]; + let mut spans = SpanMapper::new(); + let mut iter = children.iter().peekable(); + + let first_line_indent = ParElem::first_line_indent_in(*styles); + if !first_line_indent.is_zero() + && consecutive + && AlignElem::alignment_in(*styles).x.resolve(*styles) + == TextElem::dir_in(*styles).start().into() + { + full.push(SPACING_REPLACE); + segments.push((Segment::Spacing(first_line_indent.into()), *styles)); + } + + let hang = ParElem::hanging_indent_in(*styles); + if !hang.is_zero() { + full.push(SPACING_REPLACE); + segments.push((Segment::Spacing((-hang).into()), *styles)); + } + + while let Some(mut child) = iter.next() { + let outer = styles; + let mut styles = *styles; + if let Some((elem, local)) = child.to_styled() { + child = elem; + styles = outer.chain(local); + } + + let segment = if child.is::() { + full.push(' '); + Segment::Text(1) + } else if let Some(elem) = child.to::() { + let prev = full.len(); + if let Some(case) = TextElem::case_in(styles) { + full.push_str(&case.apply(&elem.text())); + } else { + full.push_str(&elem.text()); + } + Segment::Text(full.len() - prev) + } else if let Some(elem) = child.to::() { + if elem.amount().is_zero() { + continue; + } + + full.push(SPACING_REPLACE); + Segment::Spacing(elem.amount()) + } else if let Some(elem) = child.to::() { + let c = if elem.justify(styles) { '\u{2028}' } else { '\n' }; + full.push(c); + Segment::Text(c.len_utf8()) + } else if let Some(elem) = child.to::() { + let prev = full.len(); + if SmartQuoteElem::enabled_in(styles) { + let lang = TextElem::lang_in(styles); + let region = TextElem::region_in(styles); + let quotes = Quotes::from_lang(lang, region); + let peeked = iter.peek().and_then(|child| { + let child = if let Some((child, _)) = child.to_styled() { + child + } else { + child + }; + if let Some(elem) = child.to::() { + elem.text().chars().next() + } else if child.is::() { + Some('"') + } else if child.is::() + || child.is::() + || child.is::() + { + Some(SPACING_REPLACE) + } else { + Some(OBJ_REPLACE) + } + }); + + full.push_str(quoter.quote("es, elem.double(styles), peeked)); + } else { + full.push(if elem.double(styles) { '"' } else { '\'' }); + } + Segment::Text(full.len() - prev) + } else if let Some(elem) = child.to::() { + full.push(OBJ_REPLACE); + Segment::Equation(elem) + } else if let Some(elem) = child.to::() { + let frac = elem.width(styles).is_fractional(); + full.push(if frac { SPACING_REPLACE } else { OBJ_REPLACE }); + Segment::Box(elem, frac) + } else if child.is::() { + Segment::Meta + } else { + bail!(child.span(), "unexpected paragraph child"); + }; + + if let Some(last) = full.chars().last() { + quoter.last(last); + } + + spans.push(segment.len(), child.span()); + + if let (Some((Segment::Text(last_len), last_styles)), Segment::Text(len)) = + (segments.last_mut(), segment) + { + if *last_styles == styles { + *last_len += len; + continue; + } + } + + segments.push((segment, styles)); + } + + Ok((full, segments, spans)) +} + +/// Prepare paragraph layout by shaping the whole paragraph and layouting all +/// contained inline-level content. +fn prepare<'a>( + vt: &mut Vt, + children: &'a [Content], + text: &'a str, + segments: Vec<(Segment<'a>, StyleChain<'a>)>, + spans: SpanMapper, + styles: StyleChain<'a>, + region: Size, +) -> SourceResult> { + let bidi = BidiInfo::new( + text, + match TextElem::dir_in(styles) { + Dir::LTR => Some(BidiLevel::ltr()), + Dir::RTL => Some(BidiLevel::rtl()), + _ => None, + }, + ); + + let mut cursor = 0; + let mut items = vec![]; + + // Shape / layout the children and collect them into items. + for (segment, styles) in segments { + let end = cursor + segment.len(); + match segment { + Segment::Text(_) => { + shape_range(&mut items, vt, &bidi, cursor..end, &spans, styles); + } + Segment::Spacing(spacing) => match spacing { + Spacing::Rel(v) => { + let resolved = v.resolve(styles).relative_to(region.x); + items.push(Item::Absolute(resolved)); + } + Spacing::Fr(v) => { + items.push(Item::Fractional(v, None)); + } + }, + Segment::Equation(equation) => { + let pod = Regions::one(region, Axes::splat(false)); + let mut frame = equation.layout(vt, styles, pod)?.into_frame(); + frame.translate(Point::with_y(TextElem::baseline_in(styles))); + items.push(Item::Frame(frame)); + } + Segment::Box(elem, _) => { + if let Sizing::Fr(v) = elem.width(styles) { + items.push(Item::Fractional(v, Some((elem, styles)))); + } else { + let pod = Regions::one(region, Axes::splat(false)); + let mut frame = elem.layout(vt, styles, pod)?.into_frame(); + frame.translate(Point::with_y(TextElem::baseline_in(styles))); + items.push(Item::Frame(frame)); + } + } + Segment::Meta => { + let mut frame = Frame::new(Size::zero()); + frame.meta(styles, true); + items.push(Item::Meta(frame)); + } + } + + cursor = end; + } + + Ok(Preparation { + bidi, + items, + spans, + styles, + hyphenate: shared_get(styles, children, TextElem::hyphenate_in), + lang: shared_get(styles, children, TextElem::lang_in), + align: AlignElem::alignment_in(styles).x.resolve(styles), + justify: ParElem::justify_in(styles), + hang: ParElem::hanging_indent_in(styles), + }) +} + +/// Group a range of text by BiDi level and script, shape the runs and generate +/// items for them. +fn shape_range<'a>( + items: &mut Vec>, + vt: &Vt, + bidi: &BidiInfo<'a>, + range: Range, + spans: &SpanMapper, + styles: StyleChain<'a>, +) { + let lang = TextElem::lang_in(styles); + let region = TextElem::region_in(styles); + let mut process = |range: Range, level: BidiLevel| { + let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL }; + let shaped = + shape(vt, range.start, &bidi.text[range], spans, styles, dir, lang, region); + items.push(Item::Text(shaped)); + }; + + let mut prev_level = BidiLevel::ltr(); + let mut prev_script = Script::Unknown; + let mut cursor = range.start; + + // Group by embedding level and script. + for i in range.clone() { + if !bidi.text.is_char_boundary(i) { + continue; + } + + let level = bidi.levels[i]; + let script = + bidi.text[i..].chars().next().map_or(Script::Unknown, |c| c.script()); + + if level != prev_level || !is_compatible(script, prev_script) { + if cursor < i { + process(cursor..i, prev_level); + } + cursor = i; + prev_level = level; + prev_script = script; + } else if is_generic_script(prev_script) { + prev_script = script; + } + } + + process(cursor..range.end, prev_level); +} + +/// Whether this is not a specific script. +fn is_generic_script(script: Script) -> bool { + matches!(script, Script::Unknown | Script::Common | Script::Inherited) +} + +/// Whether these script can be part of the same shape run. +fn is_compatible(a: Script, b: Script) -> bool { + is_generic_script(a) || is_generic_script(b) || a == b +} + +/// Get a style property, but only if it is the same for all children of the +/// paragraph. +fn shared_get( + styles: StyleChain<'_>, + children: &[Content], + getter: fn(StyleChain) -> T, +) -> Option { + let value = getter(styles); + children + .iter() + .filter_map(|child| child.to_styled()) + .all(|(_, local)| getter(styles.chain(local)) == value) + .then_some(value) +} + +/// Find suitable linebreaks. +fn linebreak<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec> { + let linebreaks = ParElem::linebreaks_in(p.styles).unwrap_or_else(|| { + if ParElem::justify_in(p.styles) { + Linebreaks::Optimized + } else { + Linebreaks::Simple + } + }); + + match linebreaks { + Linebreaks::Simple => linebreak_simple(vt, p, width), + Linebreaks::Optimized => linebreak_optimized(vt, p, width), + } +} + +/// Perform line breaking in simple first-fit style. This means that we build +/// lines greedily, always taking the longest possible line. This may lead to +/// very unbalanced line, but is fast and simple. +fn linebreak_simple<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec> { + let mut lines = vec![]; + let mut start = 0; + let mut last = None; + + for (end, mandatory, hyphen) in breakpoints(p) { + // Compute the line and its size. + let mut attempt = line(vt, p, start..end, mandatory, hyphen); + + // If the line doesn't fit anymore, we push the last fitting attempt + // into the stack and rebuild the line from the attempt's end. The + // resulting line cannot be broken up further. + if !width.fits(attempt.width) { + if let Some((last_attempt, last_end)) = last.take() { + lines.push(last_attempt); + start = last_end; + attempt = line(vt, p, start..end, mandatory, hyphen); + } + } + + // Finish the current line if there is a mandatory line break (i.e. + // due to "\n") or if the line doesn't fit horizontally already + // since then no shorter line will be possible. + if mandatory || !width.fits(attempt.width) { + lines.push(attempt); + start = end; + last = None; + } else { + last = Some((attempt, end)); + } + } + + if let Some((line, _)) = last { + lines.push(line); + } + + lines +} + +/// Perform line breaking in optimized Knuth-Plass style. Here, we use more +/// context to determine the line breaks than in the simple first-fit style. For +/// example, we might choose to cut a line short even though there is still a +/// bit of space to improve the fit of one of the following lines. The +/// Knuth-Plass algorithm is based on the idea of "cost". A line which has a +/// very tight or very loose fit has a higher cost than one that is just right. +/// Ending a line with a hyphen incurs extra cost and endings two successive +/// lines with hyphens even more. +/// +/// To find the layout with the minimal total cost the algorithm uses dynamic +/// programming: For each possible breakpoint it determines the optimal +/// paragraph layout _up to that point_. It walks over all possible start points +/// for a line ending at that point and finds the one for which the cost of the +/// line plus the cost of the optimal paragraph up to the start point (already +/// computed and stored in dynamic programming table) is minimal. The final +/// result is simply the layout determined for the last breakpoint at the end of +/// text. +fn linebreak_optimized<'a>(vt: &Vt, p: &'a Preparation<'a>, width: Abs) -> Vec> { + /// The cost of a line or paragraph layout. + type Cost = f64; + + /// An entry in the dynamic programming table. + struct Entry<'a> { + pred: usize, + total: Cost, + line: Line<'a>, + } + + // Cost parameters. + const HYPH_COST: Cost = 0.5; + const CONSECUTIVE_DASH_COST: Cost = 300.0; + const MAX_COST: Cost = 1_000_000.0; + const MIN_RATIO: f64 = -1.0; + + // Dynamic programming table. + let mut active = 0; + let mut table = vec![Entry { + pred: 0, + total: 0.0, + line: line(vt, p, 0..0, false, false), + }]; + + let em = TextElem::size_in(p.styles); + + for (end, mandatory, hyphen) in breakpoints(p) { + let k = table.len(); + let eof = end == p.bidi.text.len(); + let mut best: Option = None; + + // Find the optimal predecessor. + for (i, pred) in table.iter_mut().enumerate().skip(active) { + // Layout the line. + let start = pred.line.end; + let attempt = line(vt, p, start..end, mandatory, hyphen); + + // Determine how much the line's spaces would need to be stretched + // to make it the desired width. + let delta = width - attempt.width; + // Determine how much stretch are permitted. + let adjust = if delta >= Abs::zero() { + attempt.stretchability() + } else { + attempt.shrinkability() + }; + // Ideally, the ratio should between -1.0 and 1.0, but sometimes a value above 1.0 + // is possible, in which case the line is underfull. + let mut ratio = delta / adjust; + if ratio.is_nan() { + // The line is not stretchable, but it just fits. + // This often happens with monospace fonts and CJK texts. + ratio = 0.0; + } + if ratio > 1.0 { + // We should stretch the line above its stretchability. Now calculate the extra amount. + let extra_stretch = (delta - adjust) / attempt.justifiables() as f64; + // Normalize the amount by half Em size. + ratio = 1.0 + extra_stretch / (em / 2.0); + } + + // Determine the cost of the line. + let min_ratio = if p.justify { MIN_RATIO } else { 0.0 }; + let mut cost = if ratio < min_ratio { + // The line is overfull. This is the case if + // - justification is on, but we'd need to shrink too much + // - justification is off and the line just doesn't fit + // Since any longer line will also be overfull, we can deactivate + // this breakpoint. + active = i + 1; + MAX_COST + } else if mandatory || eof { + // This is a mandatory break and the line is not overfull, so + // all breakpoints before this one become inactive since no line + // can span above the mandatory break. + active = k; + // If ratio > 0, we need to stretch the line only when justify is needed. + // If ratio < 0, we always need to shrink the line. + if (ratio > 0.0 && attempt.justify) || ratio < 0.0 { + ratio.powi(3).abs() + } else { + 0.0 + } + } else { + // Normal line with cost of |ratio^3|. + ratio.powi(3).abs() + }; + + // Penalize hyphens. + if hyphen { + cost += HYPH_COST; + } + + // In Knuth paper, cost = (1 + 100|r|^3 + p)^2 + a, + // where r is the ratio, p=50 is penaty, and a=3000 is consecutive penaty. + // We divide the whole formula by 10, resulting (0.01 + |r|^3 + p)^2 + a, + // where p=0.5 and a=300 + cost = (0.01 + cost).powi(2); + + // Penalize two consecutive dashes (not necessarily hyphens) extra. + if attempt.dash && pred.line.dash { + cost += CONSECUTIVE_DASH_COST; + } + + // The total cost of this line and its chain of predecessors. + let total = pred.total + cost; + + // If this attempt is better than what we had before, take it! + if best.as_ref().map_or(true, |best| best.total >= total) { + best = Some(Entry { pred: i, total, line: attempt }); + } + } + + table.push(best.unwrap()); + } + + // Retrace the best path. + let mut lines = vec![]; + let mut idx = table.len() - 1; + while idx != 0 { + table.truncate(idx + 1); + let entry = table.pop().unwrap(); + lines.push(entry.line); + idx = entry.pred; + } + + lines.reverse(); + lines +} + +/// Generated by the following command: +/// +/// ```sh +/// icu4x-datagen --locales full --keys-for-bin target/debug/typst \ +/// --format blob --out library/assets/icudata.postcard --overwrite +/// ``` +/// +/// Install icu4x-datagen with `cargo install icu4x-datagen`. +static ICU_DATA: &[u8] = include_bytes!("../../assets/icudata.postcard"); + +/// Generated by the following command: +/// +/// ```sh +/// icu4x-datagen --locales zh ja --keys segmenter/line@1 --format blob \ +/// --out library/assets/cj_linebreak_data.postcard --overwrite +/// ``` +/// +/// The used icu4x-datagen should be patched by +/// https://github.com/peng1999/icu4x/commit/b9beb6cbf633d61fc3d7983e5baf7f4449fbfae5 +static CJ_LINEBREAK_DATA: &[u8] = + include_bytes!("../../assets/cj_linebreak_data.postcard"); + +/// The general line break segmenter. +static SEGMENTER: Lazy = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + LineSegmenter::try_new_lstm_with_buffer_provider(&provider).unwrap() +}); + +/// The Unicode line break properties for each code point. +static CJ_SEGMENTER: Lazy = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + let cj_blob = BlobDataProvider::try_new_from_static_blob(CJ_LINEBREAK_DATA).unwrap(); + let cj_provider = ForkByKeyProvider::new(cj_blob, provider); + LineSegmenter::try_new_lstm_with_buffer_provider(&cj_provider).unwrap() +}); + +/// The line break segmenter for Chinese/Jpanese text. +static LINEBREAK_DATA: Lazy> = Lazy::new(|| { + let provider = BlobDataProvider::try_new_from_static_blob(ICU_DATA).unwrap(); + let deser_provider = provider.as_deserializing(); + icu_properties::maps::load_line_break(&deser_provider).unwrap() +}); + +/// Determine all possible points in the text where lines can broken. +/// +/// Returns for each breakpoint the text index, whether the break is mandatory +/// (after `\n`) and whether a hyphen is required (when breaking inside of a +/// word). +fn breakpoints<'a>(p: &'a Preparation<'a>) -> Breakpoints<'a> { + let mut linebreaks = if matches!(p.lang, Some(Lang::CHINESE | Lang::JAPANESE)) { + CJ_SEGMENTER.segment_str(p.bidi.text) + } else { + SEGMENTER.segment_str(p.bidi.text) + }; + // The iterator always yields a breakpoint at index 0, we want to ignore it + linebreaks.next(); + Breakpoints { + p, + linebreaks, + syllables: None, + offset: 0, + suffix: 0, + end: 0, + mandatory: false, + } +} + +/// An iterator over the line break opportunities in a text. +struct Breakpoints<'a> { + /// The paragraph's items. + p: &'a Preparation<'a>, + /// The inner iterator over the unicode line break opportunities. + linebreaks: LineBreakIteratorUtf8<'a, 'a>, + /// Iterator over syllables of the current word. + syllables: Option>, + /// The current text offset. + offset: usize, + /// The trimmed end of the current word. + suffix: usize, + /// The untrimmed end of the current word. + end: usize, + /// Whether the break after the current word is mandatory. + mandatory: bool, +} + +impl Iterator for Breakpoints<'_> { + type Item = (usize, bool, bool); + + fn next(&mut self) -> Option { + // If we're currently in a hyphenated "word", process the next syllable. + if let Some(syllable) = self.syllables.as_mut().and_then(Iterator::next) { + self.offset += syllable.len(); + if self.offset == self.suffix { + self.offset = self.end; + } + + // Filter out hyphenation opportunities where hyphenation was + // actually disabled. + let hyphen = self.offset < self.end; + if hyphen && !self.hyphenate(self.offset) { + return self.next(); + } + + return Some((self.offset, self.mandatory && !hyphen, hyphen)); + } + + let lb = LINEBREAK_DATA.as_borrowed(); + + // Get the next "word". + self.end = self.linebreaks.next()?; + self.mandatory = + self.p.bidi.text[..self.end].chars().next_back().map_or(false, |c| { + matches!( + lb.get(c), + LineBreak::MandatoryBreak + | LineBreak::CarriageReturn + | LineBreak::LineFeed + | LineBreak::NextLine + ) || self.end == self.p.bidi.text.len() + }); + + // Hyphenate the next word. + if self.p.hyphenate != Some(false) { + if let Some(lang) = self.lang(self.offset) { + let word = &self.p.bidi.text[self.offset..self.end]; + let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic()); + if !trimmed.is_empty() { + self.suffix = self.offset + trimmed.len(); + self.syllables = Some(hypher::hyphenate(trimmed, lang)); + return self.next(); + } + } + } + + self.offset = self.end; + Some((self.end, self.mandatory, false)) + } +} + +impl Breakpoints<'_> { + /// Whether hyphenation is enabled at the given offset. + fn hyphenate(&self, offset: usize) -> bool { + self.p + .hyphenate + .or_else(|| { + let shaped = self.p.find(offset)?.text()?; + Some(TextElem::hyphenate_in(shaped.styles)) + }) + .unwrap_or(false) + } + + /// The text language at the given offset. + fn lang(&self, offset: usize) -> Option { + let lang = self.p.lang.or_else(|| { + let shaped = self.p.find(offset)?.text()?; + Some(TextElem::lang_in(shaped.styles)) + })?; + + let bytes = lang.as_str().as_bytes().try_into().ok()?; + hypher::Lang::from_iso(bytes) + } +} + +/// Create a line which spans the given range. +fn line<'a>( + vt: &Vt, + p: &'a Preparation, + mut range: Range, + mandatory: bool, + hyphen: bool, +) -> Line<'a> { + let end = range.end; + let mut justify = p.justify && end < p.bidi.text.len() && !mandatory; + + if range.is_empty() { + return Line { + bidi: &p.bidi, + end, + trimmed: range, + first: None, + inner: &[], + last: None, + width: Abs::zero(), + justify, + dash: false, + }; + } + + // Slice out the relevant items. + let (expanded, mut inner) = p.slice(range.clone()); + let mut width = Abs::zero(); + + // Reshape the last item if it's split in half or hyphenated. + let mut last = None; + let mut dash = false; + if let Some((Item::Text(shaped), before)) = inner.split_last() { + // Compute the range we want to shape, trimming whitespace at the + // end of the line. + let base = expanded.end - shaped.text.len(); + let start = range.start.max(base); + let text = &p.bidi.text[start..range.end]; + // U+200B ZERO WIDTH SPACE is used to provide a line break opportunity, + // we want to trim it too. + let trimmed = text.trim_end().trim_end_matches('\u{200B}'); + range.end = start + trimmed.len(); + + // Deal with hyphens, dashes and justification. + let shy = trimmed.ends_with('\u{ad}'); + dash = hyphen || shy || trimmed.ends_with(['-', '–', '—']); + justify |= text.ends_with('\u{2028}'); + + // Deal with CJK punctuation at line ends. + let gb_style = is_gb_style(shaped.lang, shaped.region); + let end_cjk_punct = trimmed + .ends_with(['”', '’', ',', '。', '、', ':', ';', '》', ')', '』', '」']); + + // Usually, we don't want to shape an empty string because: + // - We don't want the height of trimmed whitespace in a different + // font to be considered for the line height. + // - Even if it's in the same font, its unnecessary. + // + // There is one exception though. When the whole line is empty, we + // need the shaped empty string to make the line the appropriate + // height. That is the case exactly if the string is empty and there + // are no other items in the line. + if hyphen || start + shaped.text.len() > range.end || end_cjk_punct { + if hyphen || start < range.end || before.is_empty() { + let mut reshaped = shaped.reshape(vt, &p.spans, start..range.end); + if hyphen || shy { + reshaped.push_hyphen(vt); + } + let punct = reshaped.glyphs.last(); + if let Some(punct) = punct { + if punct.is_cjk_left_aligned_punctuation(gb_style) { + let shrink_amount = punct.shrinkability().1; + let punct = reshaped.glyphs.to_mut().last_mut().unwrap(); + punct.shrink_right(shrink_amount); + reshaped.width -= shrink_amount.at(reshaped.size); + } + } + width += reshaped.width; + last = Some(Item::Text(reshaped)); + } + + inner = before; + } + } + + // Deal with CJK punctuation at line starts. + let text = &p.bidi.text[range.start..end]; + let start_cjk_punct = text.starts_with(['“', '‘', '《', '(', '『', '「']); + + // Reshape the start item if it's split in half. + let mut first = None; + if let Some((Item::Text(shaped), after)) = inner.split_first() { + // Compute the range we want to shape. + let base = expanded.start; + let end = range.end.min(base + shaped.text.len()); + + // Reshape if necessary. + if range.start + shaped.text.len() > end || start_cjk_punct { + if range.start < end || start_cjk_punct { + let reshaped = shaped.reshape(vt, &p.spans, range.start..end); + width += reshaped.width; + first = Some(Item::Text(reshaped)); + } + + inner = after; + } + } + + if start_cjk_punct { + let reshaped = first.as_mut().or(last.as_mut()).and_then(Item::text_mut); + if let Some(reshaped) = reshaped { + if let Some(punct) = reshaped.glyphs.first() { + if punct.is_cjk_right_aligned_punctuation() { + let shrink_amount = punct.shrinkability().0; + let punct = reshaped.glyphs.to_mut().first_mut().unwrap(); + punct.shrink_left(shrink_amount); + let amount_abs = shrink_amount.at(reshaped.size); + reshaped.width -= amount_abs; + width -= amount_abs; + } + } + } + } + + // Measure the inner items. + for item in inner { + width += item.width(); + } + + Line { + bidi: &p.bidi, + trimmed: range, + end, + first, + inner, + last, + width, + justify, + dash, + } +} + +/// Combine layouted lines into one frame per region. +fn finalize( + vt: &mut Vt, + p: &Preparation, + lines: &[Line], + region: Size, + expand: bool, +) -> SourceResult { + // Determine the paragraph's width: Full width of the region if we + // should expand or there's fractional spacing, fit-to-width otherwise. + let width = if !region.x.is_finite() + || (!expand && lines.iter().all(|line| line.fr().is_zero())) + { + p.hang + lines.iter().map(|line| line.width).max().unwrap_or_default() + } else { + region.x + }; + + // Stack the lines into one frame per region. + let mut frames: Vec = lines + .iter() + .map(|line| commit(vt, p, line, width, region.y)) + .collect::>()?; + + // Prevent orphans. + let leading = ParElem::leading_in(p.styles); + if frames.len() >= 2 && !frames[1].is_empty() { + let second = frames.remove(1); + let first = &mut frames[0]; + merge(first, second, leading); + } + + // Prevent widows. + let len = frames.len(); + if len >= 2 && !frames[len - 2].is_empty() { + let second = frames.pop().unwrap(); + let first = frames.last_mut().unwrap(); + merge(first, second, leading); + } + + Ok(Fragment::frames(frames)) +} + +/// Merge two line frames +fn merge(first: &mut Frame, second: Frame, leading: Abs) { + let offset = first.height() + leading; + let total = offset + second.height(); + first.push_frame(Point::with_y(offset), second); + first.size_mut().y = total; +} + +/// Commit to a line and build its frame. +fn commit( + vt: &mut Vt, + p: &Preparation, + line: &Line, + width: Abs, + full: Abs, +) -> SourceResult { + let mut remaining = width - line.width - p.hang; + let mut offset = Abs::zero(); + + // Reorder the line from logical to visual order. + let (reordered, starts_rtl) = reorder(line); + if !starts_rtl { + offset += p.hang; + } + + // Handle hanging punctuation to the left. + if let Some(Item::Text(text)) = reordered.first() { + if let Some(glyph) = text.glyphs.first() { + if !text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (reordered.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + offset -= amount; + remaining += amount; + } + } + } + + // Handle hanging punctuation to the right. + if let Some(Item::Text(text)) = reordered.last() { + if let Some(glyph) = text.glyphs.last() { + if text.dir.is_positive() + && TextElem::overhang_in(text.styles) + && (reordered.len() > 1 || text.glyphs.len() > 1) + { + let amount = overhang(glyph.c) * glyph.x_advance.at(text.size); + remaining += amount; + } + } + } + + // Determine how much additional space is needed. + // The justicication_ratio is for the first step justification, + // extra_justification is for the last step. + // For more info on multi-step justification, see Procedures for Inter- + // Character Space Expansion in W3C document Chinese Layout Requirements. + let fr = line.fr(); + let mut justification_ratio = 0.0; + let mut extra_justification = Abs::zero(); + + let shrink = line.shrinkability(); + let stretch = line.stretchability(); + if remaining < Abs::zero() && shrink > Abs::zero() { + // Attempt to reduce the length of the line, using shrinkability. + justification_ratio = (remaining / shrink).max(-1.0); + remaining = (remaining + shrink).min(Abs::zero()); + } else if line.justify && fr.is_zero() { + // Attempt to increase the length of the line, using stretchability. + if stretch > Abs::zero() { + justification_ratio = (remaining / stretch).min(1.0); + remaining = (remaining - stretch).max(Abs::zero()); + } + + let justifiables = line.justifiables(); + if justifiables > 0 && remaining > Abs::zero() { + // Underfull line, distribute the extra space. + extra_justification = remaining / justifiables as f64; + remaining = Abs::zero(); + } + } + + let mut top = Abs::zero(); + let mut bottom = Abs::zero(); + + // Build the frames and determine the height and baseline. + let mut frames = vec![]; + for item in reordered { + let mut push = |offset: &mut Abs, frame: Frame| { + let width = frame.width(); + top.set_max(frame.baseline()); + bottom.set_max(frame.size().y - frame.baseline()); + frames.push((*offset, frame)); + *offset += width; + }; + + match item { + Item::Absolute(v) => { + offset += *v; + } + Item::Fractional(v, elem) => { + let amount = v.share(fr, remaining); + if let Some((elem, styles)) = elem { + let region = Size::new(amount, full); + let pod = Regions::one(region, Axes::new(true, false)); + let mut frame = elem.layout(vt, *styles, pod)?.into_frame(); + frame.translate(Point::with_y(TextElem::baseline_in(*styles))); + push(&mut offset, frame); + } else { + offset += amount; + } + } + Item::Text(shaped) => { + let frame = shaped.build(vt, justification_ratio, extra_justification); + push(&mut offset, frame); + } + Item::Frame(frame) | Item::Meta(frame) => { + push(&mut offset, frame.clone()); + } + } + } + + // Remaining space is distributed now. + if !fr.is_zero() { + remaining = Abs::zero(); + } + + let size = Size::new(width, top + bottom); + let mut output = Frame::new(size); + output.set_baseline(top); + + // Construct the line's frame. + for (offset, frame) in frames { + let x = offset + p.align.position(remaining); + let y = top - frame.baseline(); + output.push_frame(Point::new(x, y), frame); + } + + Ok(output) +} + +/// Return a line's items in visual order. +fn reorder<'a>(line: &'a Line<'a>) -> (Vec<&Item<'a>>, bool) { + let mut reordered = vec![]; + + // The bidi crate doesn't like empty lines. + if line.trimmed.is_empty() { + return (line.slice(line.trimmed.clone()).collect(), false); + } + + // Find the paragraph that contains the line. + let para = line + .bidi + .paragraphs + .iter() + .find(|para| para.range.contains(&line.trimmed.start)) + .unwrap(); + + // Compute the reordered ranges in visual order (left to right). + let (levels, runs) = line.bidi.visual_runs(para, line.trimmed.clone()); + let starts_rtl = levels.first().map_or(false, |level| level.is_rtl()); + + // Collect the reordered items. + for run in runs { + // Skip reset L1 runs because handling them would require reshaping + // again in some cases. + if line.bidi.levels[run.start] != levels[run.start] { + continue; + } + + let prev = reordered.len(); + reordered.extend(line.slice(run.clone())); + + if levels[run.start].is_rtl() { + reordered[prev..].reverse(); + } + } + + (reordered, starts_rtl) +} + +/// How much a character should hang into the end margin. +/// +/// For more discussion, see: +/// https://recoveringphysicist.com/21/ +fn overhang(c: char) -> f64 { + match c { + // Dashes. + '–' | '—' => 0.2, + '-' => 0.55, + + // Punctuation. + '.' | ',' => 0.8, + ':' | ';' => 0.3, + + // Arabic + '\u{60C}' | '\u{6D4}' => 0.4, + + _ => 0.0, + } +} diff --git a/crates/typst-library/src/layout/place.rs b/crates/typst-library/src/layout/place.rs new file mode 100644 index 00000000..6602948c --- /dev/null +++ b/crates/typst-library/src/layout/place.rs @@ -0,0 +1,103 @@ +use crate::prelude::*; + +/// Places content at an absolute position. +/// +/// Placed content will not affect the position of other content. Place is +/// always relative to its parent container and will be in the foreground of all +/// other content in the container. Page margins will be respected. +/// +/// +/// ## Example { #example } +/// ```example +/// #set page(height: 60pt) +/// Hello, world! +/// +/// #place( +/// top + right, +/// square( +/// width: 20pt, +/// stroke: 2pt + blue +/// ), +/// ) +/// ``` +/// +/// Display: Place +/// Category: layout +#[element(Layout, Behave)] +pub struct PlaceElem { + /// Relative to which position in the parent container to place the content. + /// + /// When an axis of the page is `{auto}` sized, all alignments relative to that + /// axis will be ignored, instead, the item will be placed in the origin of the + /// axis. + #[positional] + #[default(Axes::with_x(Some(GenAlign::Start)))] + pub alignment: Axes>, + + /// The horizontal displacement of the placed content. + /// + /// ```example + /// #set page(height: 100pt) + /// #for i in range(16) { + /// let amount = i * 4pt + /// place(center, dx: amount - 32pt, dy: amount)[A] + /// } + /// ``` + pub dx: Rel, + + /// The vertical displacement of the placed content. + pub dy: Rel, + + /// The content to place. + #[required] + pub body: Content, +} + +impl Layout for PlaceElem { + #[tracing::instrument(name = "PlaceElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let out_of_flow = self.out_of_flow(styles); + + // The pod is the base area of the region because for absolute + // placement we don't really care about the already used area. + let pod = { + let finite = regions.base().map(Abs::is_finite); + let expand = finite & (regions.expand | out_of_flow); + Regions::one(regions.base(), expand) + }; + + let child = self + .body() + .moved(Axes::new(self.dx(styles), self.dy(styles))) + .aligned(self.alignment(styles)); + + let mut frame = child.layout(vt, styles, pod)?.into_frame(); + + // If expansion is off, zero all sizes so that we don't take up any + // space in our parent. Otherwise, respect the expand settings. + let target = regions.expand.select(regions.size, Size::zero()); + frame.resize(target, Align::LEFT_TOP); + + Ok(Fragment::frame(frame)) + } +} + +impl PlaceElem { + /// Whether this element wants to be placed relative to its its parent's + /// base origin. Instead of relative to the parent's current flow/cursor + /// position. + pub fn out_of_flow(&self, styles: StyleChain) -> bool { + self.alignment(styles).y.is_some() + } +} + +impl Behave for PlaceElem { + fn behaviour(&self) -> Behaviour { + Behaviour::Ignorant + } +} diff --git a/crates/typst-library/src/layout/regions.rs b/crates/typst-library/src/layout/regions.rs new file mode 100644 index 00000000..6dd549b0 --- /dev/null +++ b/crates/typst-library/src/layout/regions.rs @@ -0,0 +1,144 @@ +use std::fmt::{self, Debug, Formatter}; + +use typst::geom::{Abs, Axes, Size}; + +/// A sequence of regions to layout into. +#[derive(Copy, Clone, Hash)] +pub struct Regions<'a> { + /// The remaining size of the first region. + pub size: Size, + /// The full height of the region for relative sizing. + pub full: Abs, + /// The height of followup regions. The width is the same for all regions. + pub backlog: &'a [Abs], + /// The height of the final region that is repeated once the backlog is + /// drained. The width is the same for all regions. + pub last: Option, + /// Whether elements should expand to fill the regions instead of shrinking + /// to fit the content. + pub expand: Axes, + /// Whether these are the root regions or direct descendants. + /// + /// True for the padded page regions and columns directly in the page, + /// false otherwise. + pub root: bool, +} + +impl Regions<'_> { + /// Create a new region sequence with exactly one region. + pub fn one(size: Size, expand: Axes) -> Self { + Self { + size, + full: size.y, + backlog: &[], + last: None, + expand, + root: false, + } + } + + /// Create a new sequence of same-size regions that repeats indefinitely. + pub fn repeat(size: Size, expand: Axes) -> Self { + Self { + size, + full: size.y, + backlog: &[], + last: Some(size.y), + expand, + root: false, + } + } + + /// The base size, which doesn't take into account that the regions is + /// already partially used up. + /// + /// This is also used for relative sizing. + pub fn base(&self) -> Size { + Size::new(self.size.x, self.full) + } + + /// Create new regions where all sizes are mapped with `f`. + /// + /// Note that since all regions must have the same width, the width returned + /// by `f` is ignored for the backlog and the final region. + pub fn map<'v, F>(&self, backlog: &'v mut Vec, mut f: F) -> Regions<'v> + where + F: FnMut(Size) -> Size, + { + let x = self.size.x; + backlog.clear(); + backlog.extend(self.backlog.iter().map(|&y| f(Size::new(x, y)).y)); + Regions { + size: f(self.size), + full: f(Size::new(x, self.full)).y, + backlog, + last: self.last.map(|y| f(Size::new(x, y)).y), + expand: self.expand, + root: false, + } + } + + /// Whether the first region is full and a region break is called for. + pub fn is_full(&self) -> bool { + Abs::zero().fits(self.size.y) && !self.in_last() + } + + /// Whether the first region is the last usable region. + /// + /// If this is true, calling `next()` will have no effect. + pub fn in_last(&self) -> bool { + self.backlog.is_empty() && self.last.map_or(true, |height| self.size.y == height) + } + + /// The same regions, but with different `root` configuration. + pub fn with_root(self, root: bool) -> Self { + Self { root, ..self } + } + + /// Advance to the next region if there is any. + pub fn next(&mut self) { + if let Some(height) = self + .backlog + .split_first() + .map(|(first, tail)| { + self.backlog = tail; + *first + }) + .or(self.last) + { + self.size.y = height; + self.full = height; + } + } + + /// An iterator that returns the sizes of the first and all following + /// regions, equivalently to what would be produced by calling + /// [`next()`](Self::next) repeatedly until all regions are exhausted. + /// This iterator may be infinite. + pub fn iter(&self) -> impl Iterator + '_ { + let first = std::iter::once(self.size); + let backlog = self.backlog.iter(); + let last = self.last.iter().cycle(); + first.chain(backlog.chain(last).map(|&h| Size::new(self.size.x, h))) + } +} + +impl Debug for Regions<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("Regions ")?; + let mut list = f.debug_list(); + let mut prev = self.size.y; + list.entry(&self.size); + for &height in self.backlog { + list.entry(&Size::new(self.size.x, height)); + prev = height; + } + if let Some(last) = self.last { + if last != prev { + list.entry(&Size::new(self.size.x, last)); + } + list.entry(&(..)); + } + list.finish() + } +} diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs new file mode 100644 index 00000000..646eb991 --- /dev/null +++ b/crates/typst-library/src/layout/repeat.rs @@ -0,0 +1,79 @@ +use crate::prelude::*; + +use super::AlignElem; + +/// Repeats content to the available space. +/// +/// This can be useful when implementing a custom index, reference, or outline. +/// +/// Space may be inserted between the instances of the body parameter, so be +/// sure to include negative space if you need the instances to overlap. +/// +/// Errors if there no bounds on the available space, as it would create +/// infinite content. +/// +/// ## Example { #example } +/// ```example +/// Sign on the dotted line: +/// #box(width: 1fr, repeat[.]) +/// +/// #set text(10pt) +/// #v(8pt, weak: true) +/// #align(right)[ +/// Berlin, the 22nd of December, 2022 +/// ] +/// ``` +/// +/// Display: Repeat +/// Category: layout +#[element(Layout)] +pub struct RepeatElem { + /// The content to repeat. + #[required] + pub body: Content, +} + +impl Layout for RepeatElem { + #[tracing::instrument(name = "RepeatElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let pod = Regions::one(regions.size, Axes::new(false, false)); + let piece = self.body().layout(vt, styles, pod)?.into_frame(); + let align = AlignElem::alignment_in(styles).x.resolve(styles); + + let fill = regions.size.x; + let width = piece.width(); + let count = (fill / width).floor(); + let remaining = fill % width; + let apart = remaining / (count - 1.0); + + let size = Size::new(regions.size.x, piece.height()); + + if !size.is_finite() { + bail!(self.span(), "repeat with no size restrictions"); + } + + let mut frame = Frame::new(size); + if piece.has_baseline() { + frame.set_baseline(piece.baseline()); + } + + let mut offset = Abs::zero(); + if count == 1.0 { + offset += align.position(remaining); + } + + if width > Abs::zero() { + for _ in 0..(count as usize).min(1000) { + frame.push_frame(Point::with_x(offset), piece.clone()); + offset += piece.width() + apart; + } + } + + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/layout/spacing.rs b/crates/typst-library/src/layout/spacing.rs new file mode 100644 index 00000000..e7dc24fb --- /dev/null +++ b/crates/typst-library/src/layout/spacing.rs @@ -0,0 +1,240 @@ +use std::cmp::Ordering; + +use crate::prelude::*; + +/// Inserts horizontal spacing into a paragraph. +/// +/// The spacing can be absolute, relative, or fractional. In the last case, the +/// remaining space on the line is distributed among all fractional spacings +/// according to their relative fractions. +/// +/// ## Example { #example } +/// ```example +/// First #h(1cm) Second \ +/// First #h(30%) Second \ +/// First #h(2fr) Second #h(1fr) Third +/// ``` +/// +/// ## Mathematical Spacing { #math-spacing } +/// In [mathematical formulas]($category/math), you can additionally use these +/// constants to add spacing between elements: `thin`, `med`, `thick`, `quad`. +/// +/// Display: Spacing (H) +/// Category: layout +#[element(Behave)] +pub struct HElem { + /// How much spacing to insert. + #[required] + pub amount: Spacing, + + /// If `{true}`, the spacing collapses at the start or end of a paragraph. + /// Moreover, from multiple adjacent weak spacings all but the largest one + /// collapse. + /// + /// ```example + /// #h(1cm, weak: true) + /// We identified a group of + /// _weak_ specimens that fail to + /// manifest in most cases. However, + /// when #h(8pt, weak: true) + /// supported + /// #h(8pt, weak: true) on both + /// sides, they do show up. + /// ``` + #[default(false)] + pub weak: bool, +} + +impl Behave for HElem { + fn behaviour(&self) -> Behaviour { + if self.amount().is_fractional() { + Behaviour::Destructive + } else if self.weak(StyleChain::default()) { + Behaviour::Weak(1) + } else { + Behaviour::Ignorant + } + } + + fn larger(&self, prev: &Content) -> bool { + let Some(prev) = prev.to::() else { return false }; + self.amount() > prev.amount() + } +} + +/// Inserts vertical spacing into a flow of blocks. +/// +/// The spacing can be absolute, relative, or fractional. In the last case, +/// the remaining space on the page is distributed among all fractional spacings +/// according to their relative fractions. +/// +/// ## Example { #example } +/// ```example +/// #grid( +/// rows: 3cm, +/// columns: 6, +/// gutter: 1fr, +/// [A #parbreak() B], +/// [A #v(0pt) B], +/// [A #v(10pt) B], +/// [A #v(0pt, weak: true) B], +/// [A #v(40%, weak: true) B], +/// [A #v(1fr) B], +/// ) +/// ``` +/// +/// Display: Spacing (V) +/// Category: layout +#[element(Behave)] +pub struct VElem { + /// How much spacing to insert. + #[required] + pub amount: Spacing, + + /// If `{true}`, the spacing collapses at the start or end of a flow. + /// Moreover, from multiple adjacent weak spacings all but the largest one + /// collapse. Weak spacings will always collapse adjacent paragraph spacing, + /// even if the paragraph spacing is larger. + /// + /// ```example + /// The following theorem is + /// foundational to the field: + /// #v(4pt, weak: true) + /// $ x^2 + y^2 = r^2 $ + /// #v(4pt, weak: true) + /// The proof is simple: + /// ``` + #[external] + pub weak: bool, + + /// The element's weakness level, see also [`Behaviour`]. + #[internal] + #[parse(args.named("weak")?.map(|v: bool| v as usize))] + pub weakness: usize, +} + +impl VElem { + /// Normal strong spacing. + pub fn strong(amount: Spacing) -> Self { + Self::new(amount).with_weakness(0) + } + + /// User-created weak spacing. + pub fn weak(amount: Spacing) -> Self { + Self::new(amount).with_weakness(1) + } + + /// Weak spacing with list attach weakness. + pub fn list_attach(amount: Spacing) -> Self { + Self::new(amount).with_weakness(2) + } + + /// Weak spacing with BlockElem::ABOVE/BELOW weakness. + pub fn block_around(amount: Spacing) -> Self { + Self::new(amount).with_weakness(3) + } + + /// Weak spacing with BlockElem::SPACING weakness. + pub fn block_spacing(amount: Spacing) -> Self { + Self::new(amount).with_weakness(4) + } +} + +impl Behave for VElem { + fn behaviour(&self) -> Behaviour { + if self.amount().is_fractional() { + Behaviour::Destructive + } else if self.weakness(StyleChain::default()) > 0 { + Behaviour::Weak(self.weakness(StyleChain::default())) + } else { + Behaviour::Ignorant + } + } + + fn larger(&self, prev: &Content) -> bool { + let Some(prev) = prev.to::() else { return false }; + self.amount() > prev.amount() + } +} + +cast! { + VElem, + v: Content => v.to::().cloned().ok_or("expected `v` element")?, +} + +/// Kinds of spacing. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Spacing { + /// Spacing specified in absolute terms and relative to the parent's size. + Rel(Rel), + /// Spacing specified as a fraction of the remaining free space in the + /// parent. + Fr(Fr), +} + +impl Spacing { + /// Whether this is fractional spacing. + pub fn is_fractional(self) -> bool { + matches!(self, Self::Fr(_)) + } + + /// Whether the spacing is actually no spacing. + pub fn is_zero(&self) -> bool { + match self { + Self::Rel(rel) => rel.is_zero(), + Self::Fr(fr) => fr.is_zero(), + } + } +} + +impl From for Spacing { + fn from(abs: Abs) -> Self { + Self::Rel(abs.into()) + } +} + +impl From for Spacing { + fn from(em: Em) -> Self { + Self::Rel(Rel::new(Ratio::zero(), em.into())) + } +} + +impl From for Spacing { + fn from(length: Length) -> Self { + Self::Rel(length.into()) + } +} + +impl From for Spacing { + fn from(fr: Fr) -> Self { + Self::Fr(fr) + } +} + +impl PartialOrd for Spacing { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (Self::Rel(a), Self::Rel(b)) => a.partial_cmp(b), + (Self::Fr(a), Self::Fr(b)) => a.partial_cmp(b), + _ => None, + } + } +} + +cast! { + Spacing, + self => match self { + Self::Rel(rel) => { + if rel.rel.is_zero() { + rel.abs.into_value() + } else if rel.abs.is_zero() { + rel.rel.into_value() + } else { + rel.into_value() + } + } + Self::Fr(fr) => fr.into_value(), + }, + v: Rel => Self::Rel(v), + v: Fr => Self::Fr(v), +} diff --git a/crates/typst-library/src/layout/stack.rs b/crates/typst-library/src/layout/stack.rs new file mode 100644 index 00000000..97305ddf --- /dev/null +++ b/crates/typst-library/src/layout/stack.rs @@ -0,0 +1,339 @@ +use super::{AlignElem, Spacing}; +use crate::prelude::*; + +/// Arranges content and spacing horizontally or vertically. +/// +/// The stack places a list of items along an axis, with optional spacing +/// between each item. +/// +/// ## Example { #example } +/// ```example +/// #stack( +/// dir: ttb, +/// rect(width: 40pt), +/// rect(width: 120pt), +/// rect(width: 90pt), +/// ) +/// ``` +/// +/// Display: Stack +/// Category: layout +#[element(Layout)] +pub struct StackElem { + /// The direction along which the items are stacked. Possible values are: + /// + /// - `{ltr}`: Left to right. + /// - `{rtl}`: Right to left. + /// - `{ttb}`: Top to bottom. + /// - `{btt}`: Bottom to top. + #[default(Dir::TTB)] + pub dir: Dir, + + /// Spacing to insert between items where no explicit spacing was provided. + pub spacing: Option, + + /// The children to stack along the axis. + #[variadic] + pub children: Vec, +} + +impl Layout for StackElem { + #[tracing::instrument(name = "StackElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let mut layouter = StackLayouter::new(self.dir(styles), regions, styles); + + // Spacing to insert before the next block. + let spacing = self.spacing(styles); + let mut deferred = None; + + for child in self.children() { + match child { + StackChild::Spacing(kind) => { + layouter.layout_spacing(kind); + deferred = None; + } + StackChild::Block(block) => { + if let Some(kind) = deferred { + layouter.layout_spacing(kind); + } + + layouter.layout_block(vt, &block, styles)?; + deferred = spacing; + } + } + } + + Ok(layouter.finish()) + } +} + +/// A child of a stack element. +#[derive(Hash)] +pub enum StackChild { + /// Spacing between other children. + Spacing(Spacing), + /// Arbitrary block-level content. + Block(Content), +} + +impl Debug for StackChild { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Spacing(kind) => kind.fmt(f), + Self::Block(block) => block.fmt(f), + } + } +} + +cast! { + StackChild, + self => match self { + Self::Spacing(spacing) => spacing.into_value(), + Self::Block(content) => content.into_value(), + }, + v: Spacing => Self::Spacing(v), + v: Content => Self::Block(v), +} + +/// Performs stack layout. +struct StackLayouter<'a> { + /// The stacking direction. + dir: Dir, + /// The axis of the stacking direction. + axis: Axis, + /// The regions to layout children into. + regions: Regions<'a>, + /// The inherited styles. + styles: StyleChain<'a>, + /// Whether the stack itself should expand to fill the region. + expand: Axes, + /// The initial size of the current region before we started subtracting. + initial: Size, + /// The generic size used by the frames for the current region. + used: Gen, + /// The sum of fractions in the current region. + fr: Fr, + /// Already layouted items whose exact positions are not yet known due to + /// fractional spacing. + items: Vec, + /// Finished frames for previous regions. + finished: Vec, +} + +/// A prepared item in a stack layout. +enum StackItem { + /// Absolute spacing between other items. + Absolute(Abs), + /// Fractional spacing between other items. + Fractional(Fr), + /// A frame for a layouted block. + Frame(Frame, Axes), +} + +impl<'a> StackLayouter<'a> { + /// Create a new stack layouter. + fn new(dir: Dir, mut regions: Regions<'a>, styles: StyleChain<'a>) -> Self { + let axis = dir.axis(); + let expand = regions.expand; + + // Disable expansion along the block axis for children. + regions.expand.set(axis, false); + + Self { + dir, + axis, + regions, + styles, + expand, + initial: regions.size, + used: Gen::zero(), + fr: Fr::zero(), + items: vec![], + finished: vec![], + } + } + + /// Add spacing along the spacing direction. + #[tracing::instrument(name = "StackLayouter::layout_spacing", skip_all)] + fn layout_spacing(&mut self, spacing: Spacing) { + match spacing { + Spacing::Rel(v) => { + // Resolve the spacing and limit it to the remaining space. + let resolved = v + .resolve(self.styles) + .relative_to(self.regions.base().get(self.axis)); + let remaining = self.regions.size.get_mut(self.axis); + let limited = resolved.min(*remaining); + if self.dir.axis() == Axis::Y { + *remaining -= limited; + } + self.used.main += limited; + self.items.push(StackItem::Absolute(resolved)); + } + Spacing::Fr(v) => { + self.fr += v; + self.items.push(StackItem::Fractional(v)); + } + } + } + + /// Layout an arbitrary block. + #[tracing::instrument(name = "StackLayouter::layout_block", skip_all)] + fn layout_block( + &mut self, + vt: &mut Vt, + block: &Content, + styles: StyleChain, + ) -> SourceResult<()> { + if self.regions.is_full() { + self.finish_region(); + } + + // Block-axis alignment of the `AlignElement` is respected by stacks. + let aligns = if let Some(align) = block.to::() { + align.alignment(styles) + } else if let Some((_, local)) = block.to_styled() { + AlignElem::alignment_in(styles.chain(local)) + } else { + AlignElem::alignment_in(styles) + } + .resolve(styles); + + let fragment = block.layout(vt, styles, self.regions)?; + let len = fragment.len(); + for (i, frame) in fragment.into_iter().enumerate() { + // Grow our size, shrink the region and save the frame for later. + let size = frame.size(); + if self.dir.axis() == Axis::Y { + self.regions.size.y -= size.y; + } + + let gen = match self.axis { + Axis::X => Gen::new(size.y, size.x), + Axis::Y => Gen::new(size.x, size.y), + }; + + self.used.main += gen.main; + self.used.cross.set_max(gen.cross); + + self.items.push(StackItem::Frame(frame, aligns)); + + if i + 1 < len { + self.finish_region(); + } + } + + Ok(()) + } + + /// Advance to the next region. + fn finish_region(&mut self) { + // Determine the size of the stack in this region depending on whether + // the region expands. + let mut size = self + .expand + .select(self.initial, self.used.into_axes(self.axis)) + .min(self.initial); + + // Expand fully if there are fr spacings. + let full = self.initial.get(self.axis); + let remaining = full - self.used.main; + if self.fr.get() > 0.0 && full.is_finite() { + self.used.main = full; + size.set(self.axis, full); + } + + let mut output = Frame::new(size); + let mut cursor = Abs::zero(); + let mut ruler: Align = self.dir.start().into(); + + // Place all frames. + for item in self.items.drain(..) { + match item { + StackItem::Absolute(v) => cursor += v, + StackItem::Fractional(v) => cursor += v.share(self.fr, remaining), + StackItem::Frame(frame, aligns) => { + if self.dir.is_positive() { + ruler = ruler.max(aligns.get(self.axis)); + } else { + ruler = ruler.min(aligns.get(self.axis)); + } + + // Align along the main axis. + let parent = size.get(self.axis); + let child = frame.size().get(self.axis); + let main = ruler.position(parent - self.used.main) + + if self.dir.is_positive() { + cursor + } else { + self.used.main - child - cursor + }; + + // Align along the cross axis. + let other = self.axis.other(); + let cross = aligns + .get(other) + .position(size.get(other) - frame.size().get(other)); + + let pos = Gen::new(cross, main).to_point(self.axis); + cursor += child; + output.push_frame(pos, frame); + } + } + } + + // Advance to the next region. + self.regions.next(); + self.initial = self.regions.size; + self.used = Gen::zero(); + self.fr = Fr::zero(); + self.finished.push(output); + } + + /// Finish layouting and return the resulting frames. + fn finish(mut self) -> Fragment { + self.finish_region(); + Fragment::frames(self.finished) + } +} + +/// A container with a main and cross component. +#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] +struct Gen { + /// The main component. + pub cross: T, + /// The cross component. + pub main: T, +} + +impl Gen { + /// Create a new instance from the two components. + const fn new(cross: T, main: T) -> Self { + Self { cross, main } + } + + /// Convert to the specific representation, given the current main axis. + fn into_axes(self, main: Axis) -> Axes { + match main { + Axis::X => Axes::new(self.main, self.cross), + Axis::Y => Axes::new(self.cross, self.main), + } + } +} + +impl Gen { + /// The zero value. + fn zero() -> Self { + Self { cross: Abs::zero(), main: Abs::zero() } + } + + /// Convert to a point. + fn to_point(self, main: Axis) -> Point { + self.into_axes(main).to_point() + } +} diff --git a/crates/typst-library/src/layout/table.rs b/crates/typst-library/src/layout/table.rs new file mode 100644 index 00000000..c2faedba --- /dev/null +++ b/crates/typst-library/src/layout/table.rs @@ -0,0 +1,334 @@ +use typst::eval::{CastInfo, Reflect}; + +use crate::layout::{AlignElem, GridLayouter, TrackSizings}; +use crate::meta::{Figurable, LocalName}; +use crate::prelude::*; + +/// A table of items. +/// +/// Tables are used to arrange content in cells. Cells can contain arbitrary +/// content, including multiple paragraphs and are specified in row-major order. +/// Because tables are just grids with configurable cell properties, refer to +/// the [grid documentation]($func/grid) for more information on how to size the +/// table tracks. +/// +/// To give a table a caption and make it [referenceable]($func/ref), put it +/// into a [figure]($func/figure). +/// +/// ## Example { #example } +/// ```example +/// #table( +/// columns: (1fr, auto, auto), +/// inset: 10pt, +/// align: horizon, +/// [], [*Area*], [*Parameters*], +/// image("cylinder.svg"), +/// $ pi h (D^2 - d^2) / 4 $, +/// [ +/// $h$: height \ +/// $D$: outer radius \ +/// $d$: inner radius +/// ], +/// image("tetrahedron.svg"), +/// $ sqrt(2) / 12 a^3 $, +/// [$a$: edge length] +/// ) +/// ``` +/// +/// Display: Table +/// Category: layout +#[element(Layout, LocalName, Figurable)] +pub struct TableElem { + /// The column sizes. See the [grid documentation]($func/grid) for more + /// information on track sizing. + pub columns: TrackSizings, + + /// The row sizes. See the [grid documentation]($func/grid) for more + /// information on track sizing. + pub rows: TrackSizings, + + /// The gaps between rows & columns. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[external] + pub gutter: TrackSizings, + + /// The gaps between columns. Takes precedence over `gutter`. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[parse( + let gutter = args.named("gutter")?; + args.named("column-gutter")?.or_else(|| gutter.clone()) + )] + pub column_gutter: TrackSizings, + + /// The gaps between rows. Takes precedence over `gutter`. See the [grid + /// documentation]($func/grid) for more information on gutters. + #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))] + pub row_gutter: TrackSizings, + + /// How to fill the cells. + /// + /// This can be a color or a function that returns a color. The function is + /// passed the cells' column and row index, starting at zero. This can be + /// used to implement striped tables. + /// + /// ```example + /// #table( + /// fill: (col, _) => if calc.odd(col) { luma(240) } else { white }, + /// align: (col, row) => + /// if row == 0 { center } + /// else if col == 0 { left } + /// else { right }, + /// columns: 4, + /// [], [*Q1*], [*Q2*], [*Q3*], + /// [Revenue:], [1000 €], [2000 €], [3000 €], + /// [Expenses:], [500 €], [1000 €], [1500 €], + /// [Profit:], [500 €], [1000 €], [1500 €], + /// ) + /// ``` + pub fill: Celled>, + + /// How to align the cells' content. + /// + /// This can either be a single alignment, an array of alignments + /// (corresponding to each column) or a function that returns an alignment. + /// The function is passed the cells' column and row index, starting at zero. + /// If set to `{auto}`, the outer alignment is used. + /// + /// ```example + /// #table( + /// columns: 3, + /// align: (x, y) => (left, center, right).at(x), + /// [Hello], [Hello], [Hello], + /// [A], [B], [C], + /// ) + /// ``` + pub align: Celled>>>, + + /// How to stroke the cells. + /// + /// See the [line's documentation]($func/line.stroke) for more details. + /// Strokes can be disabled by setting this to `{none}`. + /// + /// _Note:_ Richer stroke customization for individual cells is not yet + /// implemented, but will be in the future. In the meantime, you can use + /// the third-party [tablex library](https://github.com/PgBiel/typst-tablex/). + #[resolve] + #[fold] + #[default(Some(PartialStroke::default()))] + pub stroke: Option, + + /// How much to pad the cells' content. + #[default(Abs::pt(5.0).into())] + pub inset: Rel, + + /// The contents of the table cells. + #[variadic] + pub children: Vec, +} + +impl Layout for TableElem { + #[tracing::instrument(name = "TableElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let inset = self.inset(styles); + let align = self.align(styles); + + let tracks = Axes::new(self.columns(styles).0, self.rows(styles).0); + let gutter = Axes::new(self.column_gutter(styles).0, self.row_gutter(styles).0); + let cols = tracks.x.len().max(1); + let cells: Vec<_> = self + .children() + .into_iter() + .enumerate() + .map(|(i, child)| { + let mut child = child.padded(Sides::splat(inset)); + + let x = i % cols; + let y = i / cols; + if let Smart::Custom(alignment) = align.resolve(vt, x, y)? { + child = child.styled(AlignElem::set_alignment(alignment)); + } + + Ok(child) + }) + .collect::>()?; + + let fill = self.fill(styles); + let stroke = self.stroke(styles).map(PartialStroke::unwrap_or_default); + + // Prepare grid layout by unifying content and gutter tracks. + let layouter = GridLayouter::new( + tracks.as_deref(), + gutter.as_deref(), + &cells, + regions, + styles, + ); + + // Measure the columns and layout the grid row-by-row. + let mut layout = layouter.layout(vt)?; + + // Add lines and backgrounds. + for (frame, rows) in layout.fragment.iter_mut().zip(&layout.rows) { + if layout.cols.is_empty() || rows.is_empty() { + continue; + } + + // Render table lines. + if let Some(stroke) = &stroke { + let thickness = stroke.thickness; + let half = thickness / 2.0; + + // Render horizontal lines. + for offset in points(rows.iter().map(|piece| piece.height)) { + let target = Point::with_x(frame.width() + thickness); + let hline = Geometry::Line(target).stroked(stroke.clone()); + frame.prepend( + Point::new(-half, offset), + FrameItem::Shape(hline, self.span()), + ); + } + + // Render vertical lines. + for offset in points(layout.cols.iter().copied()) { + let target = Point::with_y(frame.height() + thickness); + let vline = Geometry::Line(target).stroked(stroke.clone()); + frame.prepend( + Point::new(offset, -half), + FrameItem::Shape(vline, self.span()), + ); + } + } + + // Render cell backgrounds. + let mut dx = Abs::zero(); + for (x, &col) in layout.cols.iter().enumerate() { + let mut dy = Abs::zero(); + for row in rows { + if let Some(fill) = fill.resolve(vt, x, row.y)? { + let pos = Point::new(dx, dy); + let size = Size::new(col, row.height); + let rect = Geometry::Rect(size).filled(fill); + frame.prepend(pos, FrameItem::Shape(rect, self.span())); + } + dy += row.height; + } + dx += col; + } + } + + Ok(layout.fragment) + } +} + +/// Turn an iterator of extents into an iterator of offsets before, in between, +/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm]. +fn points(extents: impl IntoIterator) -> impl Iterator { + let mut offset = Abs::zero(); + std::iter::once(Abs::zero()) + .chain(extents.into_iter()) + .map(move |extent| { + offset += extent; + offset + }) +} + +/// A value that can be configured per cell. +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum Celled { + /// A bare value, the same for all cells. + Value(T), + /// A closure mapping from cell coordinates to a value. + Func(Func), + /// An array of alignment values corresponding to each column. + Array(Vec), +} + +impl Celled { + /// Resolve the value based on the cell position. + pub fn resolve(&self, vt: &mut Vt, x: usize, y: usize) -> SourceResult { + Ok(match self { + Self::Value(value) => value.clone(), + Self::Func(func) => func.call_vt(vt, [x, y])?.cast().at(func.span())?, + Self::Array(array) => x + .checked_rem(array.len()) + .and_then(|i| array.get(i)) + .cloned() + .unwrap_or_default(), + }) + } +} + +impl Default for Celled { + fn default() -> Self { + Self::Value(T::default()) + } +} + +impl Reflect for Celled { + fn describe() -> CastInfo { + T::describe() + Array::describe() + Func::describe() + } + + fn castable(value: &Value) -> bool { + Array::castable(value) || Func::castable(value) || T::castable(value) + } +} + +impl IntoValue for Celled { + fn into_value(self) -> Value { + match self { + Self::Value(value) => value.into_value(), + Self::Func(func) => func.into_value(), + Self::Array(arr) => arr.into_value(), + } + } +} + +impl FromValue for Celled { + fn from_value(value: Value) -> StrResult { + match value { + Value::Func(v) => Ok(Self::Func(v)), + Value::Array(array) => Ok(Self::Array( + array.into_iter().map(T::from_value).collect::>()?, + )), + v if T::castable(&v) => Ok(Self::Value(T::from_value(v)?)), + v => Err(Self::error(&v)), + } + } +} + +impl LocalName for TableElem { + fn local_name(&self, lang: Lang, _: Option) -> &'static str { + match lang { + Lang::ALBANIAN => "Tabel", + Lang::ARABIC => "جدول", + Lang::BOKMÅL => "Tabell", + Lang::CHINESE => "表", + Lang::CZECH => "Tabulka", + Lang::DANISH => "Tabel", + Lang::DUTCH => "Tabel", + Lang::FILIPINO => "Talaan", + Lang::FRENCH => "Tableau", + Lang::GERMAN => "Tabelle", + Lang::ITALIAN => "Tabella", + Lang::NYNORSK => "Tabell", + Lang::POLISH => "Tabela", + Lang::PORTUGUESE => "Tabela", + Lang::RUSSIAN => "Таблица", + Lang::SLOVENIAN => "Tabela", + Lang::SPANISH => "Tabla", + Lang::SWEDISH => "Tabell", + Lang::TURKISH => "Tablo", + Lang::UKRAINIAN => "Таблиця", + Lang::VIETNAMESE => "Bảng", + Lang::ENGLISH | _ => "Table", + } + } +} + +impl Figurable for TableElem {} diff --git a/crates/typst-library/src/layout/terms.rs b/crates/typst-library/src/layout/terms.rs new file mode 100644 index 00000000..d693f100 --- /dev/null +++ b/crates/typst-library/src/layout/terms.rs @@ -0,0 +1,166 @@ +use super::{HElem, VElem}; +use crate::layout::{BlockElem, ParElem, Spacing}; +use crate::prelude::*; + +/// A list of terms and their descriptions. +/// +/// Displays a sequence of terms and their descriptions vertically. When the +/// descriptions span over multiple lines, they use hanging indent to +/// communicate the visual hierarchy. +/// +/// ## Example { #example } +/// ```example +/// / Ligature: A merged glyph. +/// / Kerning: A spacing adjustment +/// between two adjacent letters. +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Starting a line with a slash, +/// followed by a term, a colon and a description creates a term list item. +/// +/// Display: Term List +/// Category: layout +#[element(Layout)] +#[scope( + scope.define("item", TermItem::func()); + scope +)] +pub struct TermsElem { + /// If this is `{false}`, the items are spaced apart with [term list + /// spacing]($func/terms.spacing). If it is `{true}`, they use normal + /// [leading]($func/par.leading) instead. This makes the term list more + /// compact, which can look better if the items are short. + /// + /// In markup mode, the value of this parameter is determined based on + /// whether items are separated with a blank line. If items directly follow + /// each other, this is set to `{true}`; if items are separated by a blank + /// line, this is set to `{false}`. + /// + /// ```example + /// / Fact: If a term list has a lot + /// of text, and maybe other inline + /// content, it should not be tight + /// anymore. + /// + /// / Tip: To make it wide, simply + /// insert a blank line between the + /// items. + /// ``` + #[default(true)] + pub tight: bool, + + /// The separator between the item and the description. + /// + /// If you want to just separate them with a certain amount of space, use + /// `{h(2cm, weak: true)}` as the separator and replace `{2cm}` with your + /// desired amount of space. + /// + /// ```example + /// #set terms(separator: [: ]) + /// + /// / Colon: A nice separator symbol. + /// ``` + #[default(HElem::new(Em::new(0.6).into()).with_weak(true).pack())] + pub separator: Content, + + /// The indentation of each item. + pub indent: Length, + + /// The hanging indent of the description. + /// + /// This is in addition to the whole item's `indent`. + /// + /// ```example + /// #set terms(hanging-indent: 0pt) + /// / Term: This term list does not + /// make use of hanging indents. + /// ``` + #[default(Em::new(2.0).into())] + pub hanging_indent: Length, + + /// The spacing between the items of a wide (non-tight) term list. + /// + /// If set to `{auto}`, uses the spacing [below blocks]($func/block.below). + pub spacing: Smart, + + /// The term list's children. + /// + /// When using the term list syntax, adjacent items are automatically + /// collected into term lists, even through constructs like for loops. + /// + /// ```example + /// #for (year, product) in ( + /// "1978": "TeX", + /// "1984": "LaTeX", + /// "2019": "Typst", + /// ) [/ #product: Born in #year.] + /// ``` + #[variadic] + pub children: Vec, +} + +impl Layout for TermsElem { + #[tracing::instrument(name = "TermsElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let separator = self.separator(styles); + let indent = self.indent(styles); + let hanging_indent = self.hanging_indent(styles); + let gutter = if self.tight(styles) { + ParElem::leading_in(styles).into() + } else { + self.spacing(styles) + .unwrap_or_else(|| BlockElem::below_in(styles).amount()) + }; + + let mut seq = vec![]; + for (i, child) in self.children().into_iter().enumerate() { + if i > 0 { + seq.push(VElem::new(gutter).with_weakness(1).pack()); + } + if !indent.is_zero() { + seq.push(HElem::new(indent.into()).pack()); + } + seq.push(child.term().strong()); + seq.push(separator.clone()); + seq.push(child.description()); + } + + Content::sequence(seq) + .styled(ParElem::set_hanging_indent(hanging_indent + indent)) + .layout(vt, styles, regions) + } +} + +/// A term list item. +/// +/// Display: Term List Item +/// Category: layout +#[element] +pub struct TermItem { + /// The term described by the list item. + #[required] + pub term: Content, + + /// The description of the term. + #[required] + pub description: Content, +} + +cast! { + TermItem, + array: Array => { + let mut iter = array.into_iter(); + let (term, description) = match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => (a.cast()?, b.cast()?), + _ => bail!("array must contain exactly two entries"), + }; + Self::new(term, description) + }, + v: Content => v.to::().cloned().ok_or("expected term item or array")?, +} diff --git a/crates/typst-library/src/layout/transform.rs b/crates/typst-library/src/layout/transform.rs new file mode 100644 index 00000000..a57a5edc --- /dev/null +++ b/crates/typst-library/src/layout/transform.rs @@ -0,0 +1,194 @@ +use typst::geom::Transform; + +use crate::prelude::*; + +/// Moves content without affecting layout. +/// +/// The `move` function allows you to move content while the layout still 'sees' +/// it at the original positions. Containers will still be sized as if the +/// content was not moved. +/// +/// ## Example { #example } +/// ```example +/// #rect(inset: 0pt, move( +/// dx: 6pt, dy: 6pt, +/// rect( +/// inset: 8pt, +/// fill: white, +/// stroke: black, +/// [Abra cadabra] +/// ) +/// )) +/// ``` +/// +/// Display: Move +/// Category: layout +#[element(Layout)] +pub struct MoveElem { + /// The horizontal displacement of the content. + pub dx: Rel, + + /// The vertical displacement of the content. + pub dy: Rel, + + /// The content to move. + #[required] + pub body: Content, +} + +impl Layout for MoveElem { + #[tracing::instrument(name = "MoveElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let delta = Axes::new(self.dx(styles), self.dy(styles)).resolve(styles); + let delta = delta.zip(regions.base()).map(|(d, s)| d.relative_to(s)); + frame.translate(delta.to_point()); + Ok(Fragment::frame(frame)) + } +} + +/// Rotates content without affecting layout. +/// +/// Rotates an element by a given angle. The layout will act as if the element +/// was not rotated. +/// +/// ## Example { #example } +/// ```example +/// #stack( +/// dir: ltr, +/// spacing: 1fr, +/// ..range(16) +/// .map(i => rotate(24deg * i)[X]), +/// ) +/// ``` +/// +/// Display: Rotate +/// Category: layout +#[element(Layout)] +pub struct RotateElem { + /// The amount of rotation. + /// + /// ```example + /// #rotate(-1.571rad)[Space!] + /// ``` + /// + #[positional] + pub angle: Angle, + + /// The origin of the rotation. + /// + /// If, for instance, you wanted the bottom left corner of the rotated + /// element to stay aligned with the baseline, you would set it to `bottom + + /// left` instead. + /// + /// ```example + /// #set text(spacing: 8pt) + /// #let square = square.with(width: 8pt) + /// + /// #box(square()) + /// #box(rotate(30deg, origin: center, square())) + /// #box(rotate(30deg, origin: top + left, square())) + /// #box(rotate(30deg, origin: bottom + right, square())) + /// ``` + #[resolve] + #[fold] + #[default(Align::CENTER_HORIZON)] + pub origin: Axes>, + + /// The content to rotate. + #[required] + pub body: Content, +} + +impl Layout for RotateElem { + #[tracing::instrument(name = "RotateElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let Axes { x, y } = + self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s)); + let ts = Transform::translate(x, y) + .pre_concat(Transform::rotate(self.angle(styles))) + .pre_concat(Transform::translate(-x, -y)); + frame.transform(ts); + Ok(Fragment::frame(frame)) + } +} + +/// Scales content without affecting layout. +/// +/// Lets you mirror content by specifying a negative scale on a single axis. +/// +/// ## Example { #example } +/// ```example +/// #set align(center) +/// #scale(x: -100%)[This is mirrored.] +/// ``` +/// +/// Display: Scale +/// Category: layout +#[element(Layout)] +pub struct ScaleElem { + /// The horizontal scaling factor. + /// + /// The body will be mirrored horizontally if the parameter is negative. + #[parse( + let all = args.find()?; + args.named("x")?.or(all) + )] + #[default(Ratio::one())] + pub x: Ratio, + + /// The vertical scaling factor. + /// + /// The body will be mirrored vertically if the parameter is negative. + #[parse(args.named("y")?.or(all))] + #[default(Ratio::one())] + pub y: Ratio, + + /// The origin of the transformation. + /// + /// ```example + /// A#box(scale(75%)[A])A \ + /// B#box(scale(75%, origin: bottom + left)[B])B + /// ``` + #[resolve] + #[fold] + #[default(Align::CENTER_HORIZON)] + pub origin: Axes>, + + /// The content to scale. + #[required] + pub body: Content, +} + +impl Layout for ScaleElem { + #[tracing::instrument(name = "ScaleElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let mut frame = self.body().layout(vt, styles, pod)?.into_frame(); + let Axes { x, y } = + self.origin(styles).zip(frame.size()).map(|(o, s)| o.position(s)); + let transform = Transform::translate(x, y) + .pre_concat(Transform::scale(self.x(styles), self.y(styles))) + .pre_concat(Transform::translate(-x, -y)); + frame.transform(transform); + Ok(Fragment::frame(frame)) + } +} diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs new file mode 100644 index 00000000..0bd88501 --- /dev/null +++ b/crates/typst-library/src/lib.rs @@ -0,0 +1,141 @@ +//! Typst's standard library. + +#![allow(clippy::wildcard_in_or_patterns)] +#![allow(clippy::manual_range_contains)] +#![allow(clippy::comparison_chain)] + +pub mod compute; +pub mod layout; +pub mod math; +pub mod meta; +pub mod prelude; +pub mod shared; +pub mod symbols; +pub mod text; +pub mod visualize; + +use typst::diag::At; +use typst::eval::{LangItems, Library, Module, Scope}; +use typst::geom::Smart; +use typst::model::{Element, Styles}; + +use self::layout::LayoutRoot; + +/// Construct the standard library. +pub fn build() -> Library { + let math = math::module(); + let global = global(math.clone()); + Library { global, math, styles: styles(), items: items() } +} + +/// Construct the module with global definitions. +#[tracing::instrument(skip_all)] +fn global(math: Module) -> Module { + let mut global = Scope::deduplicating(); + + // Categories. + text::define(&mut global); + layout::define(&mut global); + visualize::define(&mut global); + meta::define(&mut global); + compute::define(&mut global); + symbols::define(&mut global); + global.define("math", math); + + Module::new("global").with_scope(global) +} + +/// Construct the standard style map. +fn styles() -> Styles { + Styles::new() +} + +/// Construct the standard lang item mapping. +fn items() -> LangItems { + LangItems { + layout: |world, content, styles| content.layout_root(world, styles), + em: text::TextElem::size_in, + dir: text::TextElem::dir_in, + space: || text::SpaceElem::new().pack(), + linebreak: || text::LinebreakElem::new().pack(), + text: |text| text::TextElem::new(text).pack(), + text_func: text::TextElem::func(), + text_str: |content| Some(content.to::()?.text()), + smart_quote: |double| text::SmartQuoteElem::new().with_double(double).pack(), + parbreak: || layout::ParbreakElem::new().pack(), + strong: |body| text::StrongElem::new(body).pack(), + emph: |body| text::EmphElem::new(body).pack(), + raw: |text, lang, block| { + let mut elem = text::RawElem::new(text).with_block(block); + if let Some(lang) = lang { + elem.push_lang(Some(lang)); + } + elem.pack() + }, + raw_languages: text::RawElem::languages, + link: |url| meta::LinkElem::from_url(url).pack(), + reference: |target, supplement| { + let mut elem = meta::RefElem::new(target); + if let Some(supplement) = supplement { + elem.push_supplement(Smart::Custom(Some(meta::Supplement::Content( + supplement, + )))); + } + elem.pack() + }, + bibliography_keys: meta::BibliographyElem::keys, + heading: |level, title| meta::HeadingElem::new(title).with_level(level).pack(), + heading_func: meta::HeadingElem::func(), + list_item: |body| layout::ListItem::new(body).pack(), + enum_item: |number, body| { + let mut elem = layout::EnumItem::new(body); + if let Some(number) = number { + elem.push_number(Some(number)); + } + elem.pack() + }, + term_item: |term, description| layout::TermItem::new(term, description).pack(), + equation: |body, block| math::EquationElem::new(body).with_block(block).pack(), + math_align_point: || math::AlignPointElem::new().pack(), + math_delimited: |open, body, close| math::LrElem::new(open + body + close).pack(), + math_attach: |base, t, b, tl, bl, tr, br| { + let mut elem = math::AttachElem::new(base); + if let Some(t) = t { + elem.push_t(Some(t)); + } + if let Some(b) = b { + elem.push_b(Some(b)); + } + if let Some(tl) = tl { + elem.push_tl(Some(tl)); + } + if let Some(bl) = bl { + elem.push_bl(Some(bl)); + } + if let Some(tr) = tr { + elem.push_tr(Some(tr)); + } + if let Some(br) = br { + elem.push_br(Some(br)); + } + elem.pack() + }, + math_accent: |base, accent| { + math::AccentElem::new(base, math::Accent::new(accent)).pack() + }, + math_frac: |num, denom| math::FracElem::new(num, denom).pack(), + math_root: |index, radicand| { + math::RootElem::new(radicand).with_index(index).pack() + }, + library_method: |vm, dynamic, method, args, span| { + if let Some(counter) = dynamic.downcast::().cloned() { + counter.call_method(vm, method, args, span) + } else if let Some(state) = dynamic.downcast::().cloned() { + state.call_method(vm, method, args, span) + } else { + Err(format!("type {} has no method `{method}`", dynamic.type_name())) + .at(span) + } + }, + } +} diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs new file mode 100644 index 00000000..d1bee198 --- /dev/null +++ b/crates/typst-library/src/math/accent.rs @@ -0,0 +1,139 @@ +use super::*; + +/// How much the accent can be shorter than the base. +const ACCENT_SHORT_FALL: Em = Em::new(0.5); + +/// Attaches an accent to a base. +/// +/// ## Example { #example } +/// ```example +/// $grave(a) = accent(a, `)$ \ +/// $arrow(a) = accent(a, arrow)$ \ +/// $tilde(a) = accent(a, \u{0303})$ +/// ``` +/// +/// Display: Accent +/// Category: math +#[element(LayoutMath)] +pub struct AccentElem { + /// The base to which the accent is applied. + /// May consist of multiple letters. + /// + /// ```example + /// $arrow(A B C)$ + /// ``` + #[required] + pub base: Content, + + /// The accent to apply to the base. + /// + /// Supported accents include: + /// + /// | Accent | Name | Codepoint | + /// | ------------- | --------------- | --------- | + /// | Grave | `grave` | ` | + /// | Acute | `acute` | `´` | + /// | Circumflex | `hat` | `^` | + /// | Tilde | `tilde` | `~` | + /// | Macron | `macron` | `¯` | + /// | Breve | `breve` | `˘` | + /// | Dot | `dot` | `.` | + /// | Double dot | `dot.double` | `¨` | + /// | Triple dot | `dot.triple` | | + /// | Quadruple dot | `dot.quad` | | + /// | Diaeresis | `diaer` | `¨` | + /// | Circle | `circle` | `∘` | + /// | Double acute | `acute.double` | `˝` | + /// | Caron | `caron` | `ˇ` | + /// | Right arrow | `arrow`, `->` | `→` | + /// | Left arrow | `arrow.l`, `<-` | `←` | + #[required] + pub accent: Accent, +} + +impl LayoutMath for AccentElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + ctx.style(ctx.style.with_cramped(true)); + let base = ctx.layout_fragment(&self.base())?; + ctx.unstyle(); + + // Preserve class to preserve automatic spacing. + let base_class = base.class().unwrap_or(MathClass::Normal); + let base_attach = match &base { + MathFragment::Glyph(base) => { + attachment(ctx, base.id, base.italics_correction) + } + _ => (base.width() + base.italics_correction()) / 2.0, + }; + + // Forcing the accent to be at least as large as the base makes it too + // wide in many case. + let Accent(c) = self.accent(); + let glyph = GlyphFragment::new(ctx, c, self.span()); + let short_fall = ACCENT_SHORT_FALL.scaled(ctx); + let variant = glyph.stretch_horizontal(ctx, base.width(), short_fall); + let accent = variant.frame; + let accent_attach = match variant.id { + Some(id) => attachment(ctx, id, variant.italics_correction), + None => accent.width() / 2.0, + }; + + // Descent is negative because the accent's ink bottom is above the + // baseline. Therefore, the default gap is the accent's negated descent + // minus the accent base height. Only if the base is very small, we need + // a larger gap so that the accent doesn't move too low. + let accent_base_height = scaled!(ctx, accent_base_height); + let gap = -accent.descent() - base.height().min(accent_base_height); + let size = Size::new(base.width(), accent.height() + gap + base.height()); + let accent_pos = Point::with_x(base_attach - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + let base_ascent = base.ascent(); + let baseline = base_pos.y + base.ascent(); + + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(accent_pos, accent); + frame.push_frame(base_pos, base.into_frame()); + ctx.push( + FrameFragment::new(ctx, frame) + .with_class(base_class) + .with_base_ascent(base_ascent), + ); + + Ok(()) + } +} + +/// The horizontal attachment position for the given glyph. +fn attachment(ctx: &MathContext, id: GlyphId, italics_correction: Abs) -> Abs { + ctx.table + .glyph_info + .and_then(|info| info.top_accent_attachments) + .and_then(|attachments| attachments.get(id)) + .map(|record| record.value.scaled(ctx)) + .unwrap_or_else(|| { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + (advance.scaled(ctx) + italics_correction) / 2.0 + }) +} + +/// An accent character. +pub struct Accent(char); + +impl Accent { + /// Normalize a character into an accent. + pub fn new(c: char) -> Self { + Self(Symbol::combining_accent(c).unwrap_or(c)) + } +} + +cast! { + Accent, + self => self.0.into_value(), + v: char => Self::new(v), + v: Content => match v.to::() { + Some(elem) => Value::Str(elem.text().into()).cast()?, + None => bail!("expected text"), + }, +} diff --git a/crates/typst-library/src/math/align.rs b/crates/typst-library/src/math/align.rs new file mode 100644 index 00000000..aee89a89 --- /dev/null +++ b/crates/typst-library/src/math/align.rs @@ -0,0 +1,63 @@ +use super::*; + +/// A math alignment point: `&`, `&&`. +/// +/// Display: Alignment Point +/// Category: math +#[element(LayoutMath)] +pub struct AlignPointElem {} + +impl LayoutMath for AlignPointElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + ctx.push(MathFragment::Align); + Ok(()) + } +} + +pub(super) struct AlignmentResult { + pub points: Vec, + pub width: Abs, +} + +/// Determine the position of the alignment points. +pub(super) fn alignments(rows: &[MathRow]) -> AlignmentResult { + let mut widths = Vec::::new(); + + let mut pending_width = Abs::zero(); + for row in rows { + let mut width = Abs::zero(); + let mut alignment_index = 0; + + for fragment in row.iter() { + if matches!(fragment, MathFragment::Align) { + if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + width = Abs::zero(); + alignment_index += 1; + } else { + width += fragment.width(); + } + } + if widths.is_empty() { + pending_width.set_max(width); + } else if alignment_index < widths.len() { + widths[alignment_index].set_max(width); + } else { + widths.push(width.max(pending_width)); + } + } + + let mut points = widths; + for i in 1..points.len() { + let prev = points[i - 1]; + points[i] += prev; + } + AlignmentResult { + width: points.last().copied().unwrap_or(pending_width), + points, + } +} diff --git a/crates/typst-library/src/math/attach.rs b/crates/typst-library/src/math/attach.rs new file mode 100644 index 00000000..fedeb908 --- /dev/null +++ b/crates/typst-library/src/math/attach.rs @@ -0,0 +1,411 @@ +use super::*; + +/// A base with optional attachments. +/// +/// ## Example { #example } +/// ```example +/// // With syntax. +/// $ sum_(i=0)^n a_i = 2^(1+i) $ +/// +/// // With function call. +/// $ attach( +/// Pi, t: alpha, b: beta, +/// tl: 1, tr: 2+3, bl: 4+5, br: 6, +/// ) $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax for attachments after the base: Use +/// the underscore (`_`) to indicate a subscript i.e. bottom attachment and the +/// hat (`^`) to indicate a superscript i.e. top attachment. +/// +/// Display: Attachment +/// Category: math +#[element(LayoutMath)] +pub struct AttachElem { + /// The base to which things are attached. + #[required] + pub base: Content, + + /// The top attachment, smartly positioned at top-right or above the base. + /// + /// You can wrap the base in `{limits()}` or `{scripts()}` to override the + /// smart positioning. + pub t: Option, + + /// The bottom attachment, smartly positioned at the bottom-right or below + /// the base. + /// + /// You can wrap the base in `{limits()}` or `{scripts()}` to override the + /// smart positioning. + pub b: Option, + + /// The top-left attachment (before the base). + pub tl: Option, + + /// The bottom-left attachment (before base). + pub bl: Option, + + /// The top-right attachment (after the base). + pub tr: Option, + + /// The bottom-right attachment (after the base). + pub br: Option, +} + +impl LayoutMath for AttachElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + type GetAttachment = fn(&AttachElem, styles: StyleChain) -> Option; + let layout_attachment = |ctx: &mut MathContext, getter: GetAttachment| { + getter(self, ctx.styles()) + .map(|elem| ctx.layout_fragment(&elem)) + .transpose() + }; + + let base = ctx.layout_fragment(&self.base())?; + + ctx.style(ctx.style.for_superscript()); + let tl = layout_attachment(ctx, Self::tl)?; + let tr = layout_attachment(ctx, Self::tr)?; + let t = layout_attachment(ctx, Self::t)?; + ctx.unstyle(); + + ctx.style(ctx.style.for_subscript()); + let bl = layout_attachment(ctx, Self::bl)?; + let br = layout_attachment(ctx, Self::br)?; + let b = layout_attachment(ctx, Self::b)?; + ctx.unstyle(); + + let limits = base.limits().active(ctx); + let (t, tr) = if limits || tr.is_some() { (t, tr) } else { (None, t) }; + let (b, br) = if limits || br.is_some() { (b, br) } else { (None, b) }; + layout_attachments(ctx, base, [tl, t, tr, bl, b, br]) + } +} + +/// Forces a base to display attachments as scripts. +/// +/// ## Example { #example } +/// ```example +/// $ scripts(sum)_1^2 != sum_1^2 $ +/// ``` +/// +/// Display: Scripts +/// Category: math +#[element(LayoutMath)] +pub struct ScriptsElem { + /// The base to attach the scripts to. + #[required] + pub body: Content, +} + +impl LayoutMath for ScriptsElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut fragment = ctx.layout_fragment(&self.body())?; + fragment.set_limits(Limits::Never); + ctx.push(fragment); + Ok(()) + } +} + +/// Forces a base to display attachments as limits. +/// +/// ## Example { #example } +/// ```example +/// $ limits(A)_1^2 != A_1^2 $ +/// ``` +/// +/// Display: Limits +/// Category: math +#[element(LayoutMath)] +pub struct LimitsElem { + /// The base to attach the limits to. + #[required] + pub body: Content, + + /// Whether to also force limits in inline equations. + /// + /// When applying limits globally (e.g., through a show rule), it is + /// typically a good idea to disable this. + #[default(true)] + pub inline: bool, +} + +impl LayoutMath for LimitsElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut fragment = ctx.layout_fragment(&self.body())?; + fragment.set_limits(if self.inline(ctx.styles()) { + Limits::Always + } else { + Limits::Display + }); + ctx.push(fragment); + Ok(()) + } +} + +/// Describes in which situation a frame should use limits for attachments. +#[derive(Debug, Copy, Clone)] +pub enum Limits { + /// Always scripts. + Never, + /// Display limits only in `display` math. + Display, + /// Always limits. + Always, +} + +impl Limits { + /// The default limit configuration if the given character is the base. + pub fn for_char(c: char) -> Self { + if Self::DEFAULT_TO_LIMITS.contains(&c) { + Limits::Display + } else { + Limits::Never + } + } + + /// Whether limits should be displayed in this context + pub fn active(&self, ctx: &MathContext) -> bool { + match self { + Self::Always => true, + Self::Display => ctx.style.size == MathSize::Display, + Self::Never => false, + } + } + + /// Unicode codepoints that should show attachments as limits in display + /// mode. + #[rustfmt::skip] + const DEFAULT_TO_LIMITS: &[char] = &[ + /* ∏ */ '\u{220F}', /* ∐ */ '\u{2210}', /* ∑ */ '\u{2211}', + /* ⋀ */ '\u{22C0}', /* ⋁ */ '\u{22C1}', + /* ⋂ */ '\u{22C2}', /* ⋃ */ '\u{22C3}', + /* ⨀ */ '\u{2A00}', /* ⨁ */ '\u{2A01}', /* ⨂ */ '\u{2A02}', + /* ⨃ */ '\u{2A03}', /* ⨄ */ '\u{2A04}', + /* ⨅ */ '\u{2A05}', /* ⨆ */ '\u{2A06}', + ]; +} + +macro_rules! measure { + ($e: ident, $attr: ident) => { + $e.as_ref().map(|e| e.$attr()).unwrap_or_default() + }; +} + +/// Layout the attachments. +fn layout_attachments( + ctx: &mut MathContext, + base: MathFragment, + [tl, t, tr, bl, b, br]: [Option; 6], +) -> SourceResult<()> { + let (shift_up, shift_down) = + compute_shifts_up_and_down(ctx, &base, [&tl, &tr, &bl, &br]); + + let sup_delta = Abs::zero(); + let sub_delta = -base.italics_correction(); + let (base_width, base_ascent, base_descent) = + (base.width(), base.ascent(), base.descent()); + let base_class = base.class().unwrap_or(MathClass::Normal); + + let ascent = base_ascent + .max(shift_up + measure!(tr, ascent)) + .max(shift_up + measure!(tl, ascent)) + .max(shift_up + measure!(t, height)); + + let descent = base_descent + .max(shift_down + measure!(br, descent)) + .max(shift_down + measure!(bl, descent)) + .max(shift_down + measure!(b, height)); + + let pre_sup_width = measure!(tl, width); + let pre_sub_width = measure!(bl, width); + let pre_width_dif = pre_sup_width - pre_sub_width; // Could be negative. + let pre_width_max = pre_sup_width.max(pre_sub_width); + let post_max_width = + (sup_delta + measure!(tr, width)).max(sub_delta + measure!(br, width)); + + let (center_frame, base_offset) = attach_top_and_bottom(ctx, base, t, b); + let base_pos = + Point::new(sup_delta + pre_width_max, ascent - base_ascent - base_offset); + if [&tl, &bl, &tr, &br].iter().all(|&e| e.is_none()) { + ctx.push(FrameFragment::new(ctx, center_frame).with_class(base_class)); + return Ok(()); + } + + let mut frame = Frame::new(Size::new( + pre_width_max + base_width + post_max_width + scaled!(ctx, space_after_script), + ascent + descent, + )); + frame.set_baseline(ascent); + frame.push_frame(base_pos, center_frame); + + if let Some(tl) = tl { + let pos = + Point::new(-pre_width_dif.min(Abs::zero()), ascent - shift_up - tl.ascent()); + frame.push_frame(pos, tl.into_frame()); + } + + if let Some(bl) = bl { + let pos = + Point::new(pre_width_dif.max(Abs::zero()), ascent + shift_down - bl.ascent()); + frame.push_frame(pos, bl.into_frame()); + } + + if let Some(tr) = tr { + let pos = Point::new( + sup_delta + pre_width_max + base_width, + ascent - shift_up - tr.ascent(), + ); + frame.push_frame(pos, tr.into_frame()); + } + + if let Some(br) = br { + let pos = Point::new( + sub_delta + pre_width_max + base_width, + ascent + shift_down - br.ascent(), + ); + frame.push_frame(pos, br.into_frame()); + } + + ctx.push(FrameFragment::new(ctx, frame).with_class(base_class)); + + Ok(()) +} + +fn attach_top_and_bottom( + ctx: &mut MathContext, + base: MathFragment, + t: Option, + b: Option, +) -> (Frame, Abs) { + let upper_gap_min = scaled!(ctx, upper_limit_gap_min); + let upper_rise_min = scaled!(ctx, upper_limit_baseline_rise_min); + let lower_gap_min = scaled!(ctx, lower_limit_gap_min); + let lower_drop_min = scaled!(ctx, lower_limit_baseline_drop_min); + + let mut base_offset = Abs::zero(); + let mut width = base.width(); + let mut height = base.height(); + + if let Some(t) = &t { + let top_gap = upper_gap_min.max(upper_rise_min - t.descent()); + width.set_max(t.width()); + height += t.height() + top_gap; + base_offset = top_gap + t.height(); + } + + if let Some(b) = &b { + let bottom_gap = lower_gap_min.max(lower_drop_min - b.ascent()); + width.set_max(b.width()); + height += b.height() + bottom_gap; + } + + let base_pos = Point::new((width - base.width()) / 2.0, base_offset); + let delta = base.italics_correction() / 2.0; + + let mut frame = Frame::new(Size::new(width, height)); + frame.set_baseline(base_pos.y + base.ascent()); + frame.push_frame(base_pos, base.into_frame()); + + if let Some(t) = t { + let top_pos = Point::with_x((width - t.width()) / 2.0 + delta); + frame.push_frame(top_pos, t.into_frame()); + } + + if let Some(b) = b { + let bottom_pos = + Point::new((width - b.width()) / 2.0 - delta, height - b.height()); + frame.push_frame(bottom_pos, b.into_frame()); + } + + (frame, base_offset) +} + +fn compute_shifts_up_and_down( + ctx: &MathContext, + base: &MathFragment, + [tl, tr, bl, br]: [&Option; 4], +) -> (Abs, Abs) { + let sup_shift_up = if ctx.style.cramped { + scaled!(ctx, superscript_shift_up_cramped) + } else { + scaled!(ctx, superscript_shift_up) + }; + + let sup_bottom_min = scaled!(ctx, superscript_bottom_min); + let sup_bottom_max_with_sub = scaled!(ctx, superscript_bottom_max_with_subscript); + let sup_drop_max = scaled!(ctx, superscript_baseline_drop_max); + let gap_min = scaled!(ctx, sub_superscript_gap_min); + let sub_shift_down = scaled!(ctx, subscript_shift_down); + let sub_top_max = scaled!(ctx, subscript_top_max); + let sub_drop_min = scaled!(ctx, subscript_baseline_drop_min); + + let mut shift_up = Abs::zero(); + let mut shift_down = Abs::zero(); + let is_char_box = is_character_box(base); + + if tl.is_some() || tr.is_some() { + let ascent = match &base { + MathFragment::Frame(frame) => frame.base_ascent, + _ => base.ascent(), + }; + shift_up = shift_up + .max(sup_shift_up) + .max(if is_char_box { Abs::zero() } else { ascent - sup_drop_max }) + .max(sup_bottom_min + measure!(tl, descent)) + .max(sup_bottom_min + measure!(tr, descent)); + } + + if bl.is_some() || br.is_some() { + shift_down = shift_down + .max(sub_shift_down) + .max(if is_char_box { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(measure!(bl, ascent) - sub_top_max) + .max(measure!(br, ascent) - sub_top_max); + } + + for (sup, sub) in [(tl, bl), (tr, br)] { + if let (Some(sup), Some(sub)) = (&sup, &sub) { + let sup_bottom = shift_up - sup.descent(); + let sub_top = sub.ascent() - shift_down; + let gap = sup_bottom - sub_top; + if gap >= gap_min { + continue; + } + + let increase = gap_min - gap; + let sup_only = + (sup_bottom_max_with_sub - sup_bottom).clamp(Abs::zero(), increase); + let rest = (increase - sup_only) / 2.0; + shift_up += sup_only + rest; + shift_down += rest; + } + } + + (shift_up, shift_down) +} + +/// Whether the fragment consists of a single character or atomic piece of text. +fn is_character_box(fragment: &MathFragment) -> bool { + match fragment { + MathFragment::Glyph(_) | MathFragment::Variant(_) => { + fragment.class() != Some(MathClass::Large) + } + MathFragment::Frame(fragment) => is_atomic_text_frame(&fragment.frame), + _ => false, + } +} + +/// Handles e.g. "sin", "log", "exp", "CustomOperator". +fn is_atomic_text_frame(frame: &Frame) -> bool { + // Meta information isn't visible or renderable, so we exclude it. + let mut iter = frame + .items() + .map(|(_, item)| item) + .filter(|item| !matches!(item, FrameItem::Meta(_, _))); + matches!(iter.next(), Some(FrameItem::Text(_))) && iter.next().is_none() +} diff --git a/crates/typst-library/src/math/cancel.rs b/crates/typst-library/src/math/cancel.rs new file mode 100644 index 00000000..f576a727 --- /dev/null +++ b/crates/typst-library/src/math/cancel.rs @@ -0,0 +1,187 @@ +use super::*; + +/// Displays a diagonal line over a part of an equation. +/// +/// This is commonly used to show the elimination of a term. +/// +/// ## Example { #example } +/// ```example +/// >>> #set page(width: 140pt) +/// Here, we can simplify: +/// $ (a dot b dot cancel(x)) / +/// cancel(x) $ +/// ``` +/// +/// Display: Cancel +/// Category: math +#[element(LayoutMath)] +pub struct CancelElem { + /// The content over which the line should be placed. + #[required] + pub body: Content, + + /// The length of the line, relative to the length of the diagonal spanning + /// the whole element being "cancelled". A value of `{100%}` would then have + /// the line span precisely the element's diagonal. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ a + cancel(x, length: #200%) + /// - cancel(x, length: #200%) $ + /// ``` + #[default(Rel::new(Ratio::one(), Abs::pt(3.0).into()))] + pub length: Rel, + + /// If the cancel line should be inverted (pointing to the top left instead + /// of top right). + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ (a cancel((b + c), inverted: #true)) / + /// cancel(b + c, inverted: #true) $ + /// ``` + #[default(false)] + pub inverted: bool, + + /// If two opposing cancel lines should be drawn, forming a cross over the + /// element. Overrides `inverted`. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel(Pi, cross: #true) $ + /// ``` + #[default(false)] + pub cross: bool, + + /// How to rotate the cancel line. See the [line's + /// documentation]($func/line.angle) for more details. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel(Pi, rotation: #30deg) $ + /// ``` + #[default(Angle::zero())] + pub rotation: Angle, + + /// How to stroke the cancel line. See the + /// [line's documentation]($func/line.stroke) for more details. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// $ cancel( + /// sum x, + /// stroke: #( + /// paint: red, + /// thickness: 1.5pt, + /// dash: "dashed", + /// ), + /// ) $ + /// ``` + #[resolve] + #[fold] + #[default(PartialStroke { + // Default stroke has 0.5pt for better visuals. + thickness: Smart::Custom(Abs::pt(0.5)), + ..Default::default() + })] + pub stroke: PartialStroke, +} + +impl LayoutMath for CancelElem { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let body = ctx.layout_fragment(&self.body())?; + // Use the same math class as the body, in order to preserve automatic spacing around it. + let body_class = body.class().unwrap_or(MathClass::Special); + let mut body = body.into_frame(); + + let styles = ctx.styles(); + let body_size = body.size(); + let span = self.span(); + let length = self.length(styles).resolve(styles); + + let stroke = self.stroke(styles).unwrap_or(Stroke { + paint: TextElem::fill_in(styles), + ..Default::default() + }); + + let invert = self.inverted(styles); + let cross = self.cross(styles); + let angle = self.rotation(styles); + + let invert_first_line = !cross && invert; + let first_line = draw_cancel_line( + length, + stroke.clone(), + invert_first_line, + angle, + body_size, + span, + ); + + // The origin of our line is the very middle of the element. + let center = body_size.to_point() / 2.0; + body.push_frame(center, first_line); + + if cross { + // Draw the second line. + let second_line = + draw_cancel_line(length, stroke, true, angle, body_size, span); + + body.push_frame(center, second_line); + } + + ctx.push(FrameFragment::new(ctx, body).with_class(body_class)); + + Ok(()) + } +} + +/// Draws a cancel line. +fn draw_cancel_line( + length: Rel, + stroke: Stroke, + invert: bool, + angle: Angle, + body_size: Size, + span: Span, +) -> Frame { + // B + // /| + // diagonal / | height + // / | + // / | + // O ---- + // width + let diagonal = body_size.to_point().hypot(); + let length = length.relative_to(diagonal); + let (width, height) = (body_size.x, body_size.y); + let mid = body_size / 2.0; + + // Scale the amount needed such that the cancel line has the given 'length' + // (reference length, or 100%, is the whole diagonal). + // Scales from the center. + let scale = length.to_raw() / diagonal.to_raw(); + + // invert horizontally if 'invert' was given + let scale_x = scale * if invert { -1.0 } else { 1.0 }; + let scale_y = scale; + let scales = Axes::new(scale_x, scale_y); + + // Draw a line from bottom left to top right of the given element, where the + // origin represents the very middle of that element, that is, a line from + // (-width / 2, height / 2) with length components (width, -height) (sign is + // inverted in the y-axis). After applying the scale, the line will have the + // correct length and orientation (inverted if needed). + let start = Axes::new(-mid.x, mid.y).zip(scales).map(|(l, s)| l * s); + let delta = Axes::new(width, -height).zip(scales).map(|(l, s)| l * s); + + let mut frame = Frame::new(body_size); + frame.push( + start.to_point(), + FrameItem::Shape(Geometry::Line(delta.to_point()).stroked(stroke), span), + ); + + // Having the middle of the line at the origin is convenient here. + frame.transform(Transform::rotate(angle)); + frame +} diff --git a/crates/typst-library/src/math/ctx.rs b/crates/typst-library/src/math/ctx.rs new file mode 100644 index 00000000..a1dc6cf4 --- /dev/null +++ b/crates/typst-library/src/math/ctx.rs @@ -0,0 +1,268 @@ +use ttf_parser::math::MathValue; +use typst::font::{FontStyle, FontWeight}; +use typst::model::realize; +use unicode_segmentation::UnicodeSegmentation; + +use super::*; + +macro_rules! scaled { + ($ctx:expr, text: $text:ident, display: $display:ident $(,)?) => { + match $ctx.style.size { + MathSize::Display => scaled!($ctx, $display), + _ => scaled!($ctx, $text), + } + }; + ($ctx:expr, $name:ident) => { + $ctx.constants.$name().scaled($ctx) + }; +} + +macro_rules! percent { + ($ctx:expr, $name:ident) => { + $ctx.constants.$name() as f64 / 100.0 + }; +} + +/// The context for math layout. +pub struct MathContext<'a, 'b, 'v> { + pub vt: &'v mut Vt<'b>, + pub regions: Regions<'static>, + pub font: &'a Font, + pub ttf: &'a ttf_parser::Face<'a>, + pub table: ttf_parser::math::Table<'a>, + pub constants: ttf_parser::math::Constants<'a>, + pub ssty_table: Option>, + pub space_width: Em, + pub fragments: Vec, + pub local: Styles, + pub style: MathStyle, + pub size: Abs, + outer: StyleChain<'a>, + style_stack: Vec<(MathStyle, Abs)>, +} + +impl<'a, 'b, 'v> MathContext<'a, 'b, 'v> { + pub fn new( + vt: &'v mut Vt<'b>, + styles: StyleChain<'a>, + regions: Regions, + font: &'a Font, + block: bool, + ) -> Self { + let table = font.ttf().tables().math.unwrap(); + let constants = table.constants.unwrap(); + + let ssty_table = font + .ttf() + .tables() + .gsub + .and_then(|gsub| { + gsub.features + .find(ttf_parser::Tag::from_bytes(b"ssty")) + .and_then(|feature| feature.lookup_indices.get(0)) + .and_then(|index| gsub.lookups.get(index)) + }) + .and_then(|ssty| { + ssty.subtables.get::(0) + }) + .and_then(|ssty| match ssty { + ttf_parser::gsub::SubstitutionSubtable::Alternate(alt_glyphs) => { + Some(alt_glyphs) + } + _ => None, + }); + + let size = TextElem::size_in(styles); + let ttf = font.ttf(); + let space_width = ttf + .glyph_index(' ') + .and_then(|id| ttf.glyph_hor_advance(id)) + .map(|advance| font.to_em(advance)) + .unwrap_or(THICK); + + let variant = variant(styles); + Self { + vt, + regions: Regions::one(regions.base(), Axes::splat(false)), + font, + ttf: font.ttf(), + table, + constants, + ssty_table, + space_width, + fragments: vec![], + local: Styles::new(), + style: MathStyle { + variant: MathVariant::Serif, + size: if block { MathSize::Display } else { MathSize::Text }, + cramped: false, + bold: variant.weight >= FontWeight::BOLD, + italic: match variant.style { + FontStyle::Normal => Smart::Auto, + FontStyle::Italic | FontStyle::Oblique => Smart::Custom(true), + }, + }, + size, + outer: styles, + style_stack: vec![], + } + } + + pub fn push(&mut self, fragment: impl Into) { + self.fragments.push(fragment.into()); + } + + pub fn extend(&mut self, fragments: Vec) { + self.fragments.extend(fragments); + } + + pub fn layout_fragment( + &mut self, + elem: &dyn LayoutMath, + ) -> SourceResult { + let row = self.layout_fragments(elem)?; + Ok(MathRow::new(row).into_fragment(self)) + } + + pub fn layout_fragments( + &mut self, + elem: &dyn LayoutMath, + ) -> SourceResult> { + let prev = std::mem::take(&mut self.fragments); + elem.layout_math(self)?; + Ok(std::mem::replace(&mut self.fragments, prev)) + } + + pub fn layout_row(&mut self, elem: &dyn LayoutMath) -> SourceResult { + let fragments = self.layout_fragments(elem)?; + Ok(MathRow::new(fragments)) + } + + pub fn layout_frame(&mut self, elem: &dyn LayoutMath) -> SourceResult { + Ok(self.layout_fragment(elem)?.into_frame()) + } + + pub fn layout_content(&mut self, content: &Content) -> SourceResult { + Ok(content + .layout(self.vt, self.outer.chain(&self.local), self.regions)? + .into_frame()) + } + + pub fn layout_text(&mut self, elem: &TextElem) -> SourceResult { + let text = elem.text(); + let span = elem.span(); + let mut chars = text.chars(); + let fragment = if let Some(mut glyph) = chars + .next() + .filter(|_| chars.next().is_none()) + .map(|c| self.style.styled_char(c)) + .and_then(|c| GlyphFragment::try_new(self, c, span)) + { + // A single letter that is available in the math font. + match self.style.size { + MathSize::Display => { + if glyph.class == Some(MathClass::Large) { + let height = scaled!(self, display_operator_min_height); + glyph.stretch_vertical(self, height, Abs::zero()).into() + } else { + glyph.into() + } + } + MathSize::Script => { + glyph.make_scriptsize(self); + glyph.into() + } + MathSize::ScriptScript => { + glyph.make_scriptscriptsize(self); + glyph.into() + } + _ => glyph.into(), + } + } else if text.chars().all(|c| c.is_ascii_digit()) { + // Numbers aren't that difficult. + let mut fragments = vec![]; + for c in text.chars() { + let c = self.style.styled_char(c); + fragments.push(GlyphFragment::new(self, c, span).into()); + } + let frame = MathRow::new(fragments).into_frame(self); + FrameFragment::new(self, frame).into() + } else { + // Anything else is handled by Typst's standard text layout. + let spaced = text.graphemes(true).nth(1).is_some(); + let mut style = self.style; + if self.style.italic == Smart::Auto { + style = style.with_italic(false); + } + let text: EcoString = text.chars().map(|c| style.styled_char(c)).collect(); + let frame = self.layout_content(&TextElem::packed(text).spanned(span))?; + FrameFragment::new(self, frame) + .with_class(MathClass::Alphabetic) + .with_spaced(spaced) + .into() + }; + Ok(fragment) + } + + pub fn styles(&self) -> StyleChain { + self.outer.chain(&self.local) + } + + pub fn realize(&mut self, content: &Content) -> SourceResult> { + realize(self.vt, content, self.outer.chain(&self.local)) + } + + pub fn style(&mut self, style: MathStyle) { + self.style_stack.push((self.style, self.size)); + let base_size = TextElem::size_in(self.styles()) / self.style.size.factor(self); + self.size = base_size * style.size.factor(self); + self.local.set(TextElem::set_size(TextSize(self.size.into()))); + self.local + .set(TextElem::set_style(if style.italic == Smart::Custom(true) { + FontStyle::Italic + } else { + FontStyle::Normal + })); + self.local.set(TextElem::set_weight(if style.bold { + FontWeight::BOLD + } else { + FontWeight::REGULAR + })); + self.style = style; + } + + pub fn unstyle(&mut self) { + (self.style, self.size) = self.style_stack.pop().unwrap(); + self.local.unset(); + self.local.unset(); + self.local.unset(); + } +} + +pub(super) trait Scaled { + fn scaled(self, ctx: &MathContext) -> Abs; +} + +impl Scaled for i16 { + fn scaled(self, ctx: &MathContext) -> Abs { + ctx.font.to_em(self).scaled(ctx) + } +} + +impl Scaled for u16 { + fn scaled(self, ctx: &MathContext) -> Abs { + ctx.font.to_em(self).scaled(ctx) + } +} + +impl Scaled for Em { + fn scaled(self, ctx: &MathContext) -> Abs { + self.at(ctx.size) + } +} + +impl Scaled for MathValue<'_> { + fn scaled(self, ctx: &MathContext) -> Abs { + self.value.scaled(ctx) + } +} diff --git a/crates/typst-library/src/math/delimited.rs b/crates/typst-library/src/math/delimited.rs new file mode 100644 index 00000000..99cd6c33 --- /dev/null +++ b/crates/typst-library/src/math/delimited.rs @@ -0,0 +1,200 @@ +use super::*; + +/// How much less high scaled delimiters can be than what they wrap. +pub(super) const DELIM_SHORT_FALL: Em = Em::new(0.1); + +/// Scales delimiters. +/// +/// While matched delimiters scale by default, this can be used to scale +/// unmatched delimiters and to control the delimiter scaling more precisely. +/// +/// ## Example { #example } +/// ```example +/// $ lr(]a, b/2]) $ +/// $ lr(]sum_(x=1)^n] x, size: #50%) $ +/// ``` +/// +/// Display: Left/Right +/// Category: math +#[element(LayoutMath)] +pub struct LrElem { + /// The size of the brackets, relative to the height of the wrapped content. + pub size: Smart>, + + /// The delimited content, including the delimiters. + #[required] + #[parse( + let mut body = Content::empty(); + for (i, arg) in args.all::()?.into_iter().enumerate() { + if i > 0 { + body += TextElem::packed(','); + } + body += arg; + } + body + )] + pub body: Content, +} + +impl LayoutMath for LrElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut body = self.body(); + if let Some(elem) = body.to::() { + if elem.size(ctx.styles()).is_auto() { + body = elem.body(); + } + } + + let mut fragments = ctx.layout_fragments(&body)?; + let axis = scaled!(ctx, axis_height); + let max_extent = fragments + .iter() + .map(|fragment| (fragment.ascent() - axis).max(fragment.descent() + axis)) + .max() + .unwrap_or_default(); + + let height = self + .size(ctx.styles()) + .unwrap_or(Rel::one()) + .resolve(ctx.styles()) + .relative_to(2.0 * max_extent); + + match fragments.as_mut_slice() { + [one] => scale(ctx, one, height, None), + [first, .., last] => { + scale(ctx, first, height, Some(MathClass::Opening)); + scale(ctx, last, height, Some(MathClass::Closing)); + } + _ => {} + } + + ctx.extend(fragments); + + Ok(()) + } +} + +/// Scale a math fragment to a height. +fn scale( + ctx: &mut MathContext, + fragment: &mut MathFragment, + height: Abs, + apply: Option, +) { + if matches!( + fragment.class(), + Some(MathClass::Opening | MathClass::Closing | MathClass::Fence) + ) { + let glyph = match fragment { + MathFragment::Glyph(glyph) => glyph.clone(), + MathFragment::Variant(variant) => { + GlyphFragment::new(ctx, variant.c, variant.span) + } + _ => return, + }; + + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + *fragment = + MathFragment::Variant(glyph.stretch_vertical(ctx, height, short_fall)); + + if let Some(class) = apply { + fragment.set_class(class); + } + } +} + +/// Floors an expression. +/// +/// ## Example { #example } +/// ```example +/// $ floor(x/2) $ +/// ``` +/// +/// Display: Floor +/// Category: math +#[func] +pub fn floor( + /// The expression to floor. + body: Content, +) -> Content { + delimited(body, '⌊', '⌋') +} + +/// Ceils an expression. +/// +/// ## Example { #example } +/// ```example +/// $ ceil(x/2) $ +/// ``` +/// +/// Display: Ceil +/// Category: math +#[func] +pub fn ceil( + /// The expression to ceil. + body: Content, +) -> Content { + delimited(body, '⌈', '⌉') +} + +/// Rounds an expression. +/// +/// ## Example { #example } +/// ```example +/// $ round(x/2) $ +/// ``` +/// +/// Display: Round +/// Category: math +#[func] +pub fn round( + /// The expression to round. + body: Content, +) -> Content { + delimited(body, '⌊', '⌉') +} + +/// Takes the absolute value of an expression. +/// +/// ## Example { #example } +/// ```example +/// $ abs(x/2) $ +/// ``` +/// +/// +/// Display: Abs +/// Category: math +#[func] +pub fn abs( + /// The expression to take the absolute value of. + body: Content, +) -> Content { + delimited(body, '|', '|') +} + +/// Takes the norm of an expression. +/// +/// ## Example { #example } +/// ```example +/// $ norm(x/2) $ +/// ``` +/// +/// Display: Norm +/// Category: math +#[func] +pub fn norm( + /// The expression to take the norm of. + body: Content, +) -> Content { + delimited(body, '‖', '‖') +} + +fn delimited(body: Content, left: char, right: char) -> Content { + LrElem::new(Content::sequence([ + TextElem::packed(left), + body, + TextElem::packed(right), + ])) + .pack() +} diff --git a/crates/typst-library/src/math/frac.rs b/crates/typst-library/src/math/frac.rs new file mode 100644 index 00000000..0e1f78cc --- /dev/null +++ b/crates/typst-library/src/math/frac.rs @@ -0,0 +1,148 @@ +use super::*; + +const FRAC_AROUND: Em = Em::new(0.1); + +/// A mathematical fraction. +/// +/// ## Example { #example } +/// ```example +/// $ 1/2 < (x+1)/2 $ +/// $ ((x+1)) / 2 = frac(a, b) $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Use a slash to turn neighbouring +/// expressions into a fraction. Multiple atoms can be grouped into a single +/// expression using round grouping parenthesis. Such parentheses are removed +/// from the output, but you can nest multiple to force them. +/// +/// Display: Fraction +/// Category: math +#[element(LayoutMath)] +pub struct FracElem { + /// The fraction's numerator. + #[required] + pub num: Content, + + /// The fraction's denominator. + #[required] + pub denom: Content, +} + +impl LayoutMath for FracElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, &self.num(), &self.denom(), false, self.span()) + } +} + +/// A binomial expression. +/// +/// ## Example { #example } +/// ```example +/// $ binom(n, k) $ +/// ``` +/// +/// Display: Binomial +/// Category: math +#[element(LayoutMath)] +pub struct BinomElem { + /// The binomial's upper index. + #[required] + pub upper: Content, + + /// The binomial's lower index. + #[required] + pub lower: Content, +} + +impl LayoutMath for BinomElem { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, &self.upper(), &self.lower(), true, self.span()) + } +} + +/// Layout a fraction or binomial. +fn layout( + ctx: &mut MathContext, + num: &Content, + denom: &Content, + binom: bool, + span: Span, +) -> SourceResult<()> { + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + let axis = scaled!(ctx, axis_height); + let thickness = scaled!(ctx, fraction_rule_thickness); + let shift_up = scaled!( + ctx, + text: fraction_numerator_shift_up, + display: fraction_numerator_display_style_shift_up, + ); + let shift_down = scaled!( + ctx, + text: fraction_denominator_shift_down, + display: fraction_denominator_display_style_shift_down, + ); + let num_min = scaled!( + ctx, + text: fraction_numerator_gap_min, + display: fraction_num_display_style_gap_min, + ); + let denom_min = scaled!( + ctx, + text: fraction_denominator_gap_min, + display: fraction_denom_display_style_gap_min, + ); + + ctx.style(ctx.style.for_numerator()); + let num = ctx.layout_frame(num)?; + ctx.unstyle(); + + ctx.style(ctx.style.for_denominator()); + let denom = ctx.layout_frame(denom)?; + ctx.unstyle(); + + let around = FRAC_AROUND.scaled(ctx); + let num_gap = (shift_up - axis - num.descent()).max(num_min + thickness / 2.0); + let denom_gap = (shift_down + axis - denom.ascent()).max(denom_min + thickness / 2.0); + + let line_width = num.width().max(denom.width()); + let width = line_width + 2.0 * around; + let height = num.height() + num_gap + thickness + denom_gap + denom.height(); + let size = Size::new(width, height); + let num_pos = Point::with_x((width - num.width()) / 2.0); + let line_pos = + Point::new((width - line_width) / 2.0, num.height() + num_gap + thickness / 2.0); + let denom_pos = Point::new((width - denom.width()) / 2.0, height - denom.height()); + let baseline = line_pos.y + axis; + + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(num_pos, num); + frame.push_frame(denom_pos, denom); + + if binom { + ctx.push( + GlyphFragment::new(ctx, '(', span).stretch_vertical(ctx, height, short_fall), + ); + ctx.push(FrameFragment::new(ctx, frame)); + ctx.push( + GlyphFragment::new(ctx, ')', span).stretch_vertical(ctx, height, short_fall), + ); + } else { + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(line_width)).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness, + ..Stroke::default() + }), + span, + ), + ); + ctx.push(FrameFragment::new(ctx, frame)); + } + + Ok(()) +} diff --git a/crates/typst-library/src/math/fragment.rs b/crates/typst-library/src/math/fragment.rs new file mode 100644 index 00000000..139ce07b --- /dev/null +++ b/crates/typst-library/src/math/fragment.rs @@ -0,0 +1,414 @@ +use super::*; +use ttf_parser::gsub::AlternateSet; + +#[derive(Debug, Clone)] +pub enum MathFragment { + Glyph(GlyphFragment), + Variant(VariantFragment), + Frame(FrameFragment), + Spacing(Abs), + Space(Abs), + Linebreak, + Align, +} + +impl MathFragment { + pub fn size(&self) -> Size { + Size::new(self.width(), self.height()) + } + + pub fn width(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.width, + Self::Variant(variant) => variant.frame.width(), + Self::Frame(fragment) => fragment.frame.width(), + Self::Spacing(amount) => *amount, + Self::Space(amount) => *amount, + _ => Abs::zero(), + } + } + + pub fn height(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.height(), + Self::Variant(variant) => variant.frame.height(), + Self::Frame(fragment) => fragment.frame.height(), + _ => Abs::zero(), + } + } + + pub fn ascent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.ascent, + Self::Variant(variant) => variant.frame.ascent(), + Self::Frame(fragment) => fragment.frame.baseline(), + _ => Abs::zero(), + } + } + + pub fn descent(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.descent, + Self::Variant(variant) => variant.frame.descent(), + Self::Frame(fragment) => fragment.frame.descent(), + _ => Abs::zero(), + } + } + + pub fn class(&self) -> Option { + match self { + Self::Glyph(glyph) => glyph.class, + Self::Variant(variant) => variant.class, + Self::Frame(fragment) => Some(fragment.class), + _ => None, + } + } + + pub fn style(&self) -> Option { + match self { + Self::Glyph(glyph) => Some(glyph.style), + Self::Variant(variant) => Some(variant.style), + Self::Frame(fragment) => Some(fragment.style), + _ => None, + } + } + + pub fn font_size(&self) -> Option { + match self { + Self::Glyph(glyph) => Some(glyph.font_size), + Self::Variant(variant) => Some(variant.font_size), + Self::Frame(fragment) => Some(fragment.font_size), + _ => None, + } + } + + pub fn set_class(&mut self, class: MathClass) { + match self { + Self::Glyph(glyph) => glyph.class = Some(class), + Self::Variant(variant) => variant.class = Some(class), + Self::Frame(fragment) => fragment.class = class, + _ => {} + } + } + + pub fn set_limits(&mut self, limits: Limits) { + match self { + Self::Glyph(glyph) => glyph.limits = limits, + Self::Variant(variant) => variant.limits = limits, + Self::Frame(fragment) => fragment.limits = limits, + _ => {} + } + } + + pub fn is_spaced(&self) -> bool { + match self { + MathFragment::Frame(frame) => frame.spaced, + _ => self.class() == Some(MathClass::Fence), + } + } + + pub fn italics_correction(&self) -> Abs { + match self { + Self::Glyph(glyph) => glyph.italics_correction, + Self::Variant(variant) => variant.italics_correction, + _ => Abs::zero(), + } + } + + pub fn into_frame(self) -> Frame { + match self { + Self::Glyph(glyph) => glyph.into_frame(), + Self::Variant(variant) => variant.frame, + Self::Frame(fragment) => fragment.frame, + _ => Frame::new(self.size()), + } + } + + pub fn limits(&self) -> Limits { + match self { + MathFragment::Glyph(glyph) => glyph.limits, + MathFragment::Variant(variant) => variant.limits, + MathFragment::Frame(fragment) => fragment.limits, + _ => Limits::Never, + } + } +} + +impl From for MathFragment { + fn from(glyph: GlyphFragment) -> Self { + Self::Glyph(glyph) + } +} + +impl From for MathFragment { + fn from(variant: VariantFragment) -> Self { + Self::Variant(variant) + } +} + +impl From for MathFragment { + fn from(fragment: FrameFragment) -> Self { + Self::Frame(fragment) + } +} + +#[derive(Clone)] +pub struct GlyphFragment { + pub id: GlyphId, + pub c: char, + pub font: Font, + pub lang: Lang, + pub fill: Paint, + pub width: Abs, + pub ascent: Abs, + pub descent: Abs, + pub italics_correction: Abs, + pub style: MathStyle, + pub font_size: Abs, + pub class: Option, + pub span: Span, + pub meta: Vec, + pub limits: Limits, +} + +impl GlyphFragment { + pub fn new(ctx: &MathContext, c: char, span: Span) -> Self { + let id = ctx.ttf.glyph_index(c).unwrap_or_default(); + Self::with_id(ctx, c, id, span) + } + + pub fn try_new(ctx: &MathContext, c: char, span: Span) -> Option { + let c = ctx.style.styled_char(c); + let id = ctx.ttf.glyph_index(c)?; + Some(Self::with_id(ctx, c, id, span)) + } + + pub fn with_id(ctx: &MathContext, c: char, id: GlyphId, span: Span) -> Self { + let class = match c { + ':' => Some(MathClass::Relation), + _ => unicode_math_class::class(c), + }; + let mut fragment = Self { + id, + c, + font: ctx.font.clone(), + lang: TextElem::lang_in(ctx.styles()), + fill: TextElem::fill_in(ctx.styles()), + style: ctx.style, + font_size: ctx.size, + width: Abs::zero(), + ascent: Abs::zero(), + descent: Abs::zero(), + limits: Limits::for_char(c), + italics_correction: Abs::zero(), + class, + span, + meta: MetaElem::data_in(ctx.styles()), + }; + fragment.set_id(ctx, id); + fragment + } + + /// Sets element id and boxes in appropriate way without changing other + /// styles. This is used to replace the glyph with a stretch variant. + pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { + let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); + let italics = italics_correction(ctx, id).unwrap_or_default(); + let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { + x_min: 0, + y_min: 0, + x_max: 0, + y_max: 0, + }); + + let mut width = advance.scaled(ctx); + if !is_extended_shape(ctx, id) { + width += italics; + } + + self.id = id; + self.width = width; + self.ascent = bbox.y_max.scaled(ctx); + self.descent = -bbox.y_min.scaled(ctx); + self.italics_correction = italics; + } + + pub fn height(&self) -> Abs { + self.ascent + self.descent + } + + pub fn into_variant(self) -> VariantFragment { + VariantFragment { + c: self.c, + id: Some(self.id), + style: self.style, + font_size: self.font_size, + italics_correction: self.italics_correction, + class: self.class, + span: self.span, + limits: self.limits, + frame: self.into_frame(), + } + } + + pub fn into_frame(self) -> Frame { + let item = TextItem { + font: self.font.clone(), + size: self.font_size, + fill: self.fill, + lang: self.lang, + text: self.c.into(), + glyphs: vec![Glyph { + id: self.id.0, + x_advance: Em::from_length(self.width, self.font_size), + x_offset: Em::zero(), + range: 0..self.c.len_utf8() as u16, + span: (self.span, 0), + }], + }; + let size = Size::new(self.width, self.ascent + self.descent); + let mut frame = Frame::new(size); + frame.set_baseline(self.ascent); + frame.push(Point::with_y(self.ascent), FrameItem::Text(item)); + frame.meta_iter(self.meta); + frame + } + + pub fn make_scriptsize(&mut self, ctx: &MathContext) { + let alt_id = + script_alternatives(ctx, self.id).and_then(|alts| alts.alternates.get(0)); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } + + pub fn make_scriptscriptsize(&mut self, ctx: &MathContext) { + let alts = script_alternatives(ctx, self.id); + let alt_id = alts + .and_then(|alts| alts.alternates.get(1).or_else(|| alts.alternates.get(0))); + + if let Some(alt_id) = alt_id { + self.set_id(ctx, alt_id); + } + } +} + +impl Debug for GlyphFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "GlyphFragment({:?})", self.c) + } +} + +#[derive(Clone)] +pub struct VariantFragment { + pub c: char, + pub id: Option, + pub italics_correction: Abs, + pub frame: Frame, + pub style: MathStyle, + pub font_size: Abs, + pub class: Option, + pub span: Span, + pub limits: Limits, +} + +impl Debug for VariantFragment { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "VariantFragment({:?})", self.c) + } +} + +#[derive(Debug, Clone)] +pub struct FrameFragment { + pub frame: Frame, + pub style: MathStyle, + pub font_size: Abs, + pub class: MathClass, + pub limits: Limits, + pub spaced: bool, + pub base_ascent: Abs, +} + +impl FrameFragment { + pub fn new(ctx: &MathContext, mut frame: Frame) -> Self { + let base_ascent = frame.ascent(); + frame.meta(ctx.styles(), false); + Self { + frame, + font_size: ctx.size, + style: ctx.style, + class: MathClass::Normal, + limits: Limits::Never, + spaced: false, + base_ascent, + } + } + + pub fn with_class(self, class: MathClass) -> Self { + Self { class, ..self } + } + + pub fn with_limits(self, limits: Limits) -> Self { + Self { limits, ..self } + } + + pub fn with_spaced(self, spaced: bool) -> Self { + Self { spaced, ..self } + } + + pub fn with_base_ascent(self, base_ascent: Abs) -> Self { + Self { base_ascent, ..self } + } +} + +/// Look up the italics correction for a glyph. +fn italics_correction(ctx: &MathContext, id: GlyphId) -> Option { + Some(ctx.table.glyph_info?.italic_corrections?.get(id)?.scaled(ctx)) +} + +/// Look up the script/scriptscript alternates for a glyph +fn script_alternatives<'a>( + ctx: &MathContext<'a, '_, '_>, + id: GlyphId, +) -> Option> { + ctx.ssty_table.and_then(|ssty| { + ssty.coverage.get(id).and_then(|index| ssty.alternate_sets.get(index)) + }) +} + +/// Look up the italics correction for a glyph. +fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { + ctx.table + .glyph_info + .and_then(|info| info.extended_shapes) + .and_then(|info| info.get(id)) + .is_some() +} + +/// Look up a kerning value at a specific corner and height. +/// +/// This can be integrated once we've found a font that actually provides this +/// data. +#[allow(unused)] +fn kern_at_height( + ctx: &MathContext, + id: GlyphId, + corner: Corner, + height: Abs, +) -> Option { + let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; + let kern = match corner { + Corner::TopLeft => kerns.top_left, + Corner::TopRight => kerns.top_right, + Corner::BottomRight => kerns.bottom_right, + Corner::BottomLeft => kerns.bottom_left, + }?; + + let mut i = 0; + while i < kern.count() && height > kern.height(i)?.scaled(ctx) { + i += 1; + } + + Some(kern.kern(i)?.scaled(ctx)) +} diff --git a/crates/typst-library/src/math/matrix.rs b/crates/typst-library/src/math/matrix.rs new file mode 100644 index 00000000..aaccc332 --- /dev/null +++ b/crates/typst-library/src/math/matrix.rs @@ -0,0 +1,313 @@ +use super::*; + +const ROW_GAP: Em = Em::new(0.5); +const COL_GAP: Em = Em::new(0.5); +const VERTICAL_PADDING: Ratio = Ratio::new(0.1); + +/// A column vector. +/// +/// Content in the vector's elements can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ vec(a, b, c) dot vec(1, 2, 3) +/// = a + 2b + 3c $ +/// ``` +/// +/// Display: Vector +/// Category: math +#[element(LayoutMath)] +pub struct VecElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.vec(delim: "[") + /// $ vec(1, 2) $ + /// ``` + #[default(Some(Delimiter::Paren))] + pub delim: Option, + + /// The elements of the vector. + #[variadic] + pub children: Vec, +} + +impl LayoutMath for VecElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_vec_body(ctx, &self.children(), Align::Center)?; + layout_delimiters( + ctx, + frame, + delim.map(Delimiter::open), + delim.map(Delimiter::close), + self.span(), + ) + } +} + +/// A matrix. +/// +/// The elements of a row should be separated by commas, while the rows +/// themselves should be separated by semicolons. The semicolon syntax merges +/// preceding arguments separated by commas into an array. You can also use this +/// special syntax of math function calls to define custom functions that take +/// 2D data. +/// +/// Content in cells that are in the same row can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ mat( +/// 1, 2, ..., 10; +/// 2, 2, ..., 10; +/// dots.v, dots.v, dots.down, dots.v; +/// 10, 10, ..., 10; +/// ) $ +/// ``` +/// +/// Display: Matrix +/// Category: math +#[element(LayoutMath)] +pub struct MatElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.mat(delim: "[") + /// $ mat(1, 2; 3, 4) $ + /// ``` + #[default(Some(Delimiter::Paren))] + pub delim: Option, + + /// An array of arrays with the rows of the matrix. + /// + /// ```example + /// #let data = ((1, 2, 3), (4, 5, 6)) + /// #let matrix = math.mat(..data) + /// $ v := matrix $ + /// ``` + #[variadic] + #[parse( + let mut rows = vec![]; + let mut width = 0; + + let values = args.all::>()?; + if values.iter().any(|spanned| matches!(spanned.v, Value::Array(_))) { + for Spanned { v, span } in values { + let array = v.cast::().at(span)?; + let row: Vec<_> = array.into_iter().map(Value::display).collect(); + width = width.max(row.len()); + rows.push(row); + } + } else { + rows = vec![values.into_iter().map(|spanned| spanned.v.display()).collect()]; + } + + for row in &mut rows { + if row.len() < width { + row.resize(width, Content::empty()); + } + } + + rows + )] + pub rows: Vec>, +} + +impl LayoutMath for MatElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_mat_body(ctx, &self.rows())?; + layout_delimiters( + ctx, + frame, + delim.map(Delimiter::open), + delim.map(Delimiter::close), + self.span(), + ) + } +} + +/// A case distinction. +/// +/// Content across different branches can be aligned with the `&` symbol. +/// +/// ## Example { #example } +/// ```example +/// $ f(x, y) := cases( +/// 1 "if" (x dot y)/2 <= 0, +/// 2 "if" x "is even", +/// 3 "if" x in NN, +/// 4 "else", +/// ) $ +/// ``` +/// +/// Display: Cases +/// Category: math +#[element(LayoutMath)] +pub struct CasesElem { + /// The delimiter to use. + /// + /// ```example + /// #set math.cases(delim: "[") + /// $ x = cases(1, 2) $ + /// ``` + #[default(Delimiter::Brace)] + pub delim: Delimiter, + + /// The branches of the case distinction. + #[variadic] + pub children: Vec, +} + +impl LayoutMath for CasesElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let delim = self.delim(ctx.styles()); + let frame = layout_vec_body(ctx, &self.children(), Align::Left)?; + layout_delimiters(ctx, frame, Some(delim.open()), None, self.span()) + } +} + +/// A vector / matrix delimiter. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum Delimiter { + /// Delimit with parentheses. + #[string("(")] + Paren, + /// Delimit with brackets. + #[string("[")] + Bracket, + /// Delimit with curly braces. + #[string("{")] + Brace, + /// Delimit with vertical bars. + #[string("|")] + Bar, + /// Delimit with double vertical bars. + #[string("||")] + DoubleBar, +} + +impl Delimiter { + /// The delimiter's opening character. + fn open(self) -> char { + match self { + Self::Paren => '(', + Self::Bracket => '[', + Self::Brace => '{', + Self::Bar => '|', + Self::DoubleBar => '‖', + } + } + + /// The delimiter's closing character. + fn close(self) -> char { + match self { + Self::Paren => ')', + Self::Bracket => ']', + Self::Brace => '}', + Self::Bar => '|', + Self::DoubleBar => '‖', + } + } +} + +/// Layout the inner contents of a vector. +fn layout_vec_body( + ctx: &mut MathContext, + column: &[Content], + align: Align, +) -> SourceResult { + let gap = ROW_GAP.scaled(ctx); + ctx.style(ctx.style.for_denominator()); + let mut flat = vec![]; + for child in column { + flat.push(ctx.layout_row(child)?); + } + ctx.unstyle(); + Ok(stack(ctx, flat, align, gap, 0)) +} + +/// Layout the inner contents of a matrix. +fn layout_mat_body(ctx: &mut MathContext, rows: &[Vec]) -> SourceResult { + let row_gap = ROW_GAP.scaled(ctx); + let col_gap = COL_GAP.scaled(ctx); + + let ncols = rows.first().map_or(0, |row| row.len()); + let nrows = rows.len(); + if ncols == 0 || nrows == 0 { + return Ok(Frame::new(Size::zero())); + } + + let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; + + ctx.style(ctx.style.for_denominator()); + let mut cols = vec![vec![]; ncols]; + for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { + for (cell, col) in row.iter().zip(&mut cols) { + let cell = ctx.layout_row(cell)?; + ascent.set_max(cell.ascent()); + descent.set_max(cell.descent()); + col.push(cell); + } + } + ctx.unstyle(); + + let mut frame = Frame::new(Size::new( + Abs::zero(), + heights.iter().map(|&(a, b)| a + b).sum::() + row_gap * (nrows - 1) as f64, + )); + let mut x = Abs::zero(); + for col in cols { + let AlignmentResult { points, width: rcol } = alignments(&col); + let mut y = Abs::zero(); + for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { + let cell = cell.into_aligned_frame(ctx, &points, Align::Center); + let pos = Point::new( + if points.is_empty() { x + (rcol - cell.width()) / 2.0 } else { x }, + y + ascent - cell.ascent(), + ); + frame.push_frame(pos, cell); + y += ascent + descent + row_gap; + } + x += rcol + col_gap; + } + frame.size_mut().x = x - col_gap; + + Ok(frame) +} + +/// Layout the outer wrapper around a vector's or matrices' body. +fn layout_delimiters( + ctx: &mut MathContext, + mut frame: Frame, + left: Option, + right: Option, + span: Span, +) -> SourceResult<()> { + let axis = scaled!(ctx, axis_height); + let short_fall = DELIM_SHORT_FALL.scaled(ctx); + let height = frame.height(); + let target = height + VERTICAL_PADDING.of(height); + frame.set_baseline(height / 2.0 + axis); + + if let Some(left) = left { + ctx.push( + GlyphFragment::new(ctx, left, span).stretch_vertical(ctx, target, short_fall), + ); + } + + ctx.push(FrameFragment::new(ctx, frame)); + + if let Some(right) = right { + ctx.push( + GlyphFragment::new(ctx, right, span) + .stretch_vertical(ctx, target, short_fall), + ); + } + + Ok(()) +} diff --git a/crates/typst-library/src/math/mod.rs b/crates/typst-library/src/math/mod.rs new file mode 100644 index 00000000..0429265f --- /dev/null +++ b/crates/typst-library/src/math/mod.rs @@ -0,0 +1,480 @@ +//! Mathematical formulas. + +#[macro_use] +mod ctx; +mod accent; +mod align; +mod attach; +mod cancel; +mod delimited; +mod frac; +mod fragment; +mod matrix; +mod op; +mod root; +mod row; +mod spacing; +mod stretch; +mod style; +mod underover; + +pub use self::accent::*; +pub use self::align::*; +pub use self::attach::*; +pub use self::cancel::*; +pub use self::delimited::*; +pub use self::frac::*; +pub use self::matrix::*; +pub use self::op::*; +pub use self::root::*; +pub use self::style::*; +pub use self::underover::*; + +use ttf_parser::{GlyphId, Rect}; +use typst::eval::{Module, Scope}; +use typst::font::{Font, FontWeight}; +use typst::model::Guard; +use typst::util::option_eq; +use unicode_math_class::MathClass; + +use self::ctx::*; +use self::fragment::*; +use self::row::*; +use self::spacing::*; +use crate::layout::{HElem, ParElem, Spacing}; +use crate::meta::Supplement; +use crate::meta::{ + Count, Counter, CounterUpdate, LocalName, Numbering, Outlinable, Refable, +}; +use crate::prelude::*; +use crate::text::{ + families, variant, FontFamily, FontList, LinebreakElem, SpaceElem, TextElem, TextSize, +}; + +/// Create a module with all math definitions. +pub fn module() -> Module { + let mut math = Scope::deduplicating(); + math.define("equation", EquationElem::func()); + math.define("text", TextElem::func()); + + // Grouping. + math.define("lr", LrElem::func()); + math.define("abs", abs_func()); + math.define("norm", norm_func()); + math.define("floor", floor_func()); + math.define("ceil", ceil_func()); + math.define("round", round_func()); + + // Attachments and accents. + math.define("attach", AttachElem::func()); + math.define("scripts", ScriptsElem::func()); + math.define("limits", LimitsElem::func()); + math.define("accent", AccentElem::func()); + math.define("underline", UnderlineElem::func()); + math.define("overline", OverlineElem::func()); + math.define("underbrace", UnderbraceElem::func()); + math.define("overbrace", OverbraceElem::func()); + math.define("underbracket", UnderbracketElem::func()); + math.define("overbracket", OverbracketElem::func()); + math.define("cancel", CancelElem::func()); + + // Fractions and matrix-likes. + math.define("frac", FracElem::func()); + math.define("binom", BinomElem::func()); + math.define("vec", VecElem::func()); + math.define("mat", MatElem::func()); + math.define("cases", CasesElem::func()); + + // Roots. + math.define("sqrt", sqrt_func()); + math.define("root", RootElem::func()); + + // Styles. + math.define("upright", upright_func()); + math.define("bold", bold_func()); + math.define("italic", italic_func()); + math.define("serif", serif_func()); + math.define("sans", sans_func()); + math.define("cal", cal_func()); + math.define("frak", frak_func()); + math.define("mono", mono_func()); + math.define("bb", bb_func()); + + math.define("display", display_func()); + math.define("inline", inline_func()); + math.define("script", script_func()); + math.define("sscript", sscript_func()); + + // Text operators. + math.define("op", OpElem::func()); + op::define(&mut math); + + // Spacings. + spacing::define(&mut math); + + // Symbols. + for (name, symbol) in crate::symbols::SYM { + math.define(*name, symbol.clone()); + } + + Module::new("math").with_scope(math) +} + +/// A mathematical equation. +/// +/// Can be displayed inline with text or as a separate block. +/// +/// ## Example { #example } +/// ```example +/// #set text(font: "New Computer Modern") +/// +/// Let $a$, $b$, and $c$ be the side +/// lengths of right-angled triangle. +/// Then, we know that: +/// $ a^2 + b^2 = c^2 $ +/// +/// Prove by induction: +/// $ sum_(k=1)^n k = (n(n+1)) / 2 $ +/// ``` +/// +/// ## Syntax { #syntax } +/// This function also has dedicated syntax: Write mathematical markup within +/// dollar signs to create an equation. Starting and ending the equation with at +/// least one space lifts it into a separate block that is centered +/// horizontally. For more details about math syntax, see the +/// [main math page]($category/math). +/// +/// Display: Equation +/// Category: math +#[element( + Locatable, Synthesize, Show, Finalize, Layout, LayoutMath, Count, LocalName, Refable, + Outlinable +)] +pub struct EquationElem { + /// Whether the equation is displayed as a separate block. + #[default(false)] + pub block: bool, + + /// How to [number]($func/numbering) block-level equations. + /// + /// ```example + /// #set math.equation(numbering: "(1)") + /// + /// We define: + /// $ phi.alt := (1 + sqrt(5)) / 2 $ + /// + /// With @ratio, we get: + /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $ + /// ``` + pub numbering: Option, + + /// A supplement for the equation. + /// + /// For references to equations, this is added before the referenced number. + /// + /// If a function is specified, it is passed the referenced equation and + /// should return content. + /// + /// ```example + /// #set math.equation(numbering: "(1)", supplement: [Eq.]) + /// + /// We define: + /// $ phi.alt := (1 + sqrt(5)) / 2 $ + /// + /// With @ratio, we get: + /// $ F_n = floor(1 / sqrt(5) phi.alt^n) $ + /// ``` + pub supplement: Smart>, + + /// The contents of the equation. + #[required] + pub body: Content, +} + +impl Synthesize for EquationElem { + fn synthesize(&mut self, vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + // Resolve the supplement. + let supplement = match self.supplement(styles) { + Smart::Auto => TextElem::packed(self.local_name_in(styles)), + Smart::Custom(None) => Content::empty(), + Smart::Custom(Some(supplement)) => supplement.resolve(vt, [self.clone()])?, + }; + + self.push_block(self.block(styles)); + self.push_numbering(self.numbering(styles)); + self.push_supplement(Smart::Custom(Some(Supplement::Content(supplement)))); + + Ok(()) + } +} + +impl Show for EquationElem { + #[tracing::instrument(name = "EquationElem::show", skip_all)] + fn show(&self, _: &mut Vt, styles: StyleChain) -> SourceResult { + let mut realized = self.clone().pack().guarded(Guard::Base(Self::func())); + if self.block(styles) { + realized = realized.aligned(Axes::with_x(Some(Align::Center.into()))) + } + Ok(realized) + } +} + +impl Finalize for EquationElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + realized + .styled(TextElem::set_weight(FontWeight::from_number(450))) + .styled(TextElem::set_font(FontList(vec![FontFamily::new( + "New Computer Modern Math", + )]))) + } +} + +impl Layout for EquationElem { + #[tracing::instrument(name = "EquationElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + const NUMBER_GUTTER: Em = Em::new(0.5); + + let block = self.block(styles); + + // Find a math font. + let variant = variant(styles); + let world = vt.world; + let Some(font) = families(styles) + .find_map(|family| { + let id = world.book().select(family.as_str(), variant)?; + let font = world.font(id)?; + let _ = font.ttf().tables().math?.constants?; + Some(font) + }) + else { + bail!(self.span(), "current font does not support math"); + }; + + let mut ctx = MathContext::new(vt, styles, regions, &font, block); + let mut frame = ctx.layout_frame(self)?; + + if block { + if let Some(numbering) = self.numbering(styles) { + let pod = Regions::one(regions.base(), Axes::splat(false)); + let counter = Counter::of(Self::func()) + .display(Some(numbering), false) + .layout(vt, styles, pod)? + .into_frame(); + + let width = if regions.size.x.is_finite() { + regions.size.x + } else { + frame.width() + + 2.0 * (counter.width() + NUMBER_GUTTER.resolve(styles)) + }; + + let height = frame.height().max(counter.height()); + frame.resize(Size::new(width, height), Align::CENTER_HORIZON); + + let x = if TextElem::dir_in(styles).is_positive() { + frame.width() - counter.width() + } else { + Abs::zero() + }; + let y = (frame.height() - counter.height()) / 2.0; + + frame.push_frame(Point::new(x, y), counter) + } + } else { + let slack = ParElem::leading_in(styles) * 0.7; + let top_edge = TextElem::top_edge_in(styles).resolve(styles, font.metrics()); + let bottom_edge = + -TextElem::bottom_edge_in(styles).resolve(styles, font.metrics()); + + let ascent = top_edge.max(frame.ascent() - slack); + let descent = bottom_edge.max(frame.descent() - slack); + frame.translate(Point::with_y(ascent - frame.baseline())); + frame.size_mut().y = ascent + descent; + } + + // Apply metadata. + frame.meta(styles, false); + + Ok(Fragment::frame(frame)) + } +} + +impl Count for EquationElem { + fn update(&self) -> Option { + (self.block(StyleChain::default()) + && self.numbering(StyleChain::default()).is_some()) + .then(|| CounterUpdate::Step(NonZeroUsize::ONE)) + } +} + +impl LocalName for EquationElem { + fn local_name(&self, lang: Lang, region: Option) -> &'static str { + match lang { + Lang::ALBANIAN => "Ekuacion", + Lang::ARABIC => "معادلة", + Lang::BOKMÅL => "Ligning", + Lang::CHINESE if option_eq(region, "TW") => "方程式", + Lang::CHINESE => "等式", + Lang::CZECH => "Rovnice", + Lang::DANISH => "Ligning", + Lang::DUTCH => "Vergelijking", + Lang::FILIPINO => "Ekwasyon", + Lang::FRENCH => "Équation", + Lang::GERMAN => "Gleichung", + Lang::ITALIAN => "Equazione", + Lang::NYNORSK => "Likning", + Lang::POLISH => "Równanie", + Lang::PORTUGUESE => "Equação", + Lang::RUSSIAN => "Уравнение", + Lang::SLOVENIAN => "Enačba", + Lang::SPANISH => "Ecuación", + Lang::SWEDISH => "Ekvation", + Lang::TURKISH => "Denklem", + Lang::UKRAINIAN => "Рівняння", + Lang::VIETNAMESE => "Phương trình", + Lang::ENGLISH | _ => "Equation", + } + } +} + +impl Refable for EquationElem { + fn supplement(&self) -> Content { + // After synthesis, this should always be custom content. + match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + } + } + + fn counter(&self) -> Counter { + Counter::of(Self::func()) + } + + fn numbering(&self) -> Option { + self.numbering(StyleChain::default()) + } +} + +impl Outlinable for EquationElem { + fn outline(&self, vt: &mut Vt) -> SourceResult> { + let Some(numbering) = self.numbering(StyleChain::default()) else { + return Ok(None); + }; + + // After synthesis, this should always be custom content. + let mut supplement = match self.supplement(StyleChain::default()) { + Smart::Custom(Some(Supplement::Content(content))) => content, + _ => Content::empty(), + }; + + if !supplement.is_empty() { + supplement += TextElem::packed("\u{a0}"); + } + + let numbers = self + .counter() + .at(vt, self.0.location().unwrap())? + .display(vt, &numbering)?; + + Ok(Some(supplement + numbers)) + } +} + +pub trait LayoutMath { + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()>; +} + +impl LayoutMath for EquationElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + self.body().layout_math(ctx) + } +} + +impl LayoutMath for Content { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + // Directly layout the body of nested equations instead of handling it + // like a normal equation so that things like this work: + // ``` + // #let my = $pi$ + // $ my r^2 $ + // ``` + if let Some(elem) = self.to::() { + return elem.layout_math(ctx); + } + + if let Some(realized) = ctx.realize(self)? { + return realized.layout_math(ctx); + } + + if let Some(children) = self.to_sequence() { + for child in children { + child.layout_math(ctx)?; + } + return Ok(()); + } + + if let Some((elem, styles)) = self.to_styled() { + if TextElem::font_in(ctx.styles().chain(styles)) + != TextElem::font_in(ctx.styles()) + { + let frame = ctx.layout_content(self)?; + ctx.push(FrameFragment::new(ctx, frame).with_spaced(true)); + return Ok(()); + } + + let prev_map = std::mem::replace(&mut ctx.local, styles.clone()); + let prev_size = ctx.size; + ctx.local.apply(prev_map.clone()); + ctx.size = TextElem::size_in(ctx.styles()); + elem.layout_math(ctx)?; + ctx.size = prev_size; + ctx.local = prev_map; + return Ok(()); + } + + if self.is::() { + ctx.push(MathFragment::Space(ctx.space_width.scaled(ctx))); + return Ok(()); + } + + if self.is::() { + ctx.push(MathFragment::Linebreak); + return Ok(()); + } + + if let Some(elem) = self.to::() { + if let Spacing::Rel(rel) = elem.amount() { + if rel.rel.is_zero() { + ctx.push(MathFragment::Spacing(rel.abs.resolve(ctx.styles()))); + } + } + return Ok(()); + } + + if let Some(elem) = self.to::() { + let fragment = ctx.layout_text(elem)?; + ctx.push(fragment); + return Ok(()); + } + + if let Some(elem) = self.with::() { + return elem.layout_math(ctx); + } + + let mut frame = ctx.layout_content(self)?; + if !frame.has_baseline() { + let axis = scaled!(ctx, axis_height); + frame.set_baseline(frame.height() / 2.0 + axis); + } + ctx.push(FrameFragment::new(ctx, frame).with_spaced(true)); + + Ok(()) + } +} diff --git a/crates/typst-library/src/math/op.rs b/crates/typst-library/src/math/op.rs new file mode 100644 index 00000000..8ad74c49 --- /dev/null +++ b/crates/typst-library/src/math/op.rs @@ -0,0 +1,113 @@ +use typst::eval::Scope; + +use super::*; + +/// A text operator in an equation. +/// +/// ## Example { #example } +/// ```example +/// $ tan x = (sin x)/(cos x) $ +/// $ op("custom", +/// limits: #true)_(n->oo) n $ +/// ``` +/// +/// ## Predefined Operators { #predefined } +/// Typst predefines the operators `arccos`, `arcsin`, `arctan`, `arg`, +/// `cos`, `cosh`, `cot`, `ctg`, `coth`, `csc`, `deg`, `det`, `dim`, +/// `exp`, `gcd`, `hom`, `mod`, `inf`, `ker`, `lg`, `lim`, `ln`, `log`, +/// `max`, `min`, `Pr`, `sec`, `sin`, `sinc`, `sinh`, `sup`, `tan`, `tg`, +/// `tanh`, `liminf`, and `limsup`. +/// +/// Display: Text Operator +/// Category: math +#[element(LayoutMath)] +pub struct OpElem { + /// The operator's text. + #[required] + pub text: EcoString, + + /// Whether the operator should show attachments as limits in display mode. + #[default(false)] + pub limits: bool, +} + +impl LayoutMath for OpElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let fragment = + ctx.layout_text(&TextElem::new(self.text()).spanned(self.span()))?; + ctx.push( + FrameFragment::new(ctx, fragment.into_frame()) + .with_class(MathClass::Large) + .with_limits(if self.limits(ctx.styles()) { + Limits::Display + } else { + Limits::Never + }), + ); + Ok(()) + } +} + +macro_rules! ops { + ($($name:ident $(: $value:literal)? $(($tts:tt))?),* $(,)?) => { + pub(super) fn define(math: &mut Scope) { + $(math.define( + stringify!($name), + OpElem::new(ops!(@name $name $(: $value)?).into()) + .with_limits(ops!(@limit $($tts)*)) + .pack() + );)* + + let dif = |d| { + HElem::new(THIN.into()).pack() + + MathStyleElem::new(TextElem::packed(d)).with_italic(Some(false)).pack() + }; + math.define("dif", dif('d')); + math.define("Dif", dif('D')); + } + }; + (@name $name:ident) => { stringify!($name) }; + (@name $name:ident: $value:literal) => { $value }; + (@limit limits) => { true }; + (@limit) => { false }; +} + +ops! { + arccos, + arcsin, + arctan, + arg, + cos, + cosh, + cot, + ctg, + coth, + csc, + deg, + det (limits), + dim, + exp, + gcd (limits), + hom, + mod, + inf (limits), + ker, + lg, + lim (limits), + ln, + log, + max (limits), + min (limits), + Pr (limits), + sec, + sin, + sinc, + sinh, + sup (limits), + tan, + tg, + tanh, + liminf: "lim inf" (limits), + limsup: "lim sup" (limits), +} diff --git a/crates/typst-library/src/math/root.rs b/crates/typst-library/src/math/root.rs new file mode 100644 index 00000000..d1c5f46a --- /dev/null +++ b/crates/typst-library/src/math/root.rs @@ -0,0 +1,156 @@ +use super::*; + +/// A square root. +/// +/// ## Example { #example } +/// ```example +/// $ sqrt(x^2) = x = sqrt(x)^2 $ +/// ``` +/// +/// Display: Square Root +/// Category: math +#[func] +pub fn sqrt( + /// The expression to take the square root of. + radicand: Content, +) -> Content { + RootElem::new(radicand).pack() +} + +/// A general root. +/// +/// ## Example { #example } +/// ```example +/// $ root(3, x) $ +/// ``` +/// +/// Display: Root +/// Category: math +#[element(LayoutMath)] +pub struct RootElem { + /// Which root of the radicand to take. + #[positional] + pub index: Option, + + /// The expression to take the root of. + #[required] + pub radicand: Content, +} + +impl LayoutMath for RootElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout(ctx, self.index(ctx.styles()).as_ref(), &self.radicand(), self.span()) + } +} + +/// Layout a root. +/// +/// https://www.w3.org/TR/mathml-core/#radicals-msqrt-mroot +fn layout( + ctx: &mut MathContext, + mut index: Option<&Content>, + radicand: &Content, + span: Span, +) -> SourceResult<()> { + let gap = scaled!( + ctx, + text: radical_vertical_gap, + display: radical_display_style_vertical_gap, + ); + let thickness = scaled!(ctx, radical_rule_thickness); + let extra_ascender = scaled!(ctx, radical_extra_ascender); + let kern_before = scaled!(ctx, radical_kern_before_degree); + let kern_after = scaled!(ctx, radical_kern_after_degree); + let raise_factor = percent!(ctx, radical_degree_bottom_raise_percent); + + // Layout radicand. + ctx.style(ctx.style.with_cramped(true)); + let radicand = ctx.layout_frame(radicand)?; + ctx.unstyle(); + + // Layout root symbol. + let target = radicand.height() + thickness + gap; + let sqrt = precomposed(ctx, index, target) + .map(|frame| { + index = None; + frame + }) + .unwrap_or_else(|| { + let glyph = GlyphFragment::new(ctx, '√', span); + glyph.stretch_vertical(ctx, target, Abs::zero()).frame + }); + + // Layout the index. + // Script-script style looks too small, we use Script style instead. + ctx.style(ctx.style.with_size(MathSize::Script)); + let index = index.map(|elem| ctx.layout_frame(elem)).transpose()?; + ctx.unstyle(); + + let gap = gap.max((sqrt.height() - radicand.height() - thickness) / 2.0); + let descent = radicand.descent() + gap; + let inner_ascent = extra_ascender + thickness + gap + radicand.ascent(); + + let mut sqrt_offset = Abs::zero(); + let mut shift_up = Abs::zero(); + let mut ascent = inner_ascent; + + if let Some(index) = &index { + sqrt_offset = kern_before + index.width() + kern_after; + shift_up = raise_factor * sqrt.height() - descent + index.descent(); + ascent.set_max(shift_up + index.ascent()); + } + + let radicant_offset = sqrt_offset + sqrt.width(); + let width = radicant_offset + radicand.width(); + let size = Size::new(width, ascent + descent); + + let sqrt_pos = Point::new(sqrt_offset, ascent - inner_ascent); + let line_pos = Point::new(radicant_offset, ascent - inner_ascent + thickness / 2.0); + let radicand_pos = Point::new(radicant_offset, ascent - radicand.ascent()); + + let mut frame = Frame::new(size); + frame.set_baseline(ascent); + + if let Some(index) = index { + let index_pos = Point::new(kern_before, ascent - shift_up - index.ascent()); + frame.push_frame(index_pos, index); + } + + frame.push_frame(sqrt_pos, sqrt); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(radicand.width())).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness, + ..Stroke::default() + }), + span, + ), + ); + + frame.push_frame(radicand_pos, radicand); + ctx.push(FrameFragment::new(ctx, frame)); + + Ok(()) +} + +/// Select a precomposed radical, if the font has it. +fn precomposed(ctx: &MathContext, index: Option<&Content>, target: Abs) -> Option { + let elem = index?.to::()?; + let c = match elem.text().as_str() { + "3" => '∛', + "4" => '∜', + _ => return None, + }; + + ctx.ttf.glyph_index(c)?; + let glyph = GlyphFragment::new(ctx, c, elem.span()); + let variant = glyph.stretch_vertical(ctx, target, Abs::zero()).frame; + if variant.height() < target { + return None; + } + + Some(variant) +} diff --git a/crates/typst-library/src/math/row.rs b/crates/typst-library/src/math/row.rs new file mode 100644 index 00000000..687f82b8 --- /dev/null +++ b/crates/typst-library/src/math/row.rs @@ -0,0 +1,258 @@ +use std::iter::once; + +use crate::layout::AlignElem; + +use super::*; + +pub const TIGHT_LEADING: Em = Em::new(0.25); + +#[derive(Debug, Default, Clone)] +pub struct MathRow(Vec); + +impl MathRow { + pub fn new(fragments: Vec) -> Self { + let iter = fragments.into_iter().peekable(); + let mut last: Option = None; + let mut space: Option = None; + let mut resolved: Vec = vec![]; + + for mut fragment in iter { + match fragment { + // Keep space only if supported by spaced fragments. + MathFragment::Space(_) => { + if last.is_some() { + space = Some(fragment); + } + continue; + } + + // Explicit spacing disables automatic spacing. + MathFragment::Spacing(_) => { + last = None; + space = None; + resolved.push(fragment); + continue; + } + + // Alignment points are resolved later. + MathFragment::Align => { + resolved.push(fragment); + continue; + } + + // New line, new things. + MathFragment::Linebreak => { + resolved.push(fragment); + space = None; + last = None; + continue; + } + + _ => {} + } + + // Convert variable operators into binary operators if something + // precedes them and they are not preceded by a operator or comparator. + if fragment.class() == Some(MathClass::Vary) + && matches!( + last.and_then(|i| resolved[i].class()), + Some( + MathClass::Normal + | MathClass::Alphabetic + | MathClass::Closing + | MathClass::Fence + ) + ) + { + fragment.set_class(MathClass::Binary); + } + + // Insert spacing between the last and this item. + if let Some(i) = last { + if let Some(s) = spacing(&resolved[i], space.take(), &fragment) { + resolved.insert(i + 1, s); + } + } + + last = Some(resolved.len()); + resolved.push(fragment); + } + + Self(resolved) + } + + pub fn iter(&self) -> std::slice::Iter<'_, MathFragment> { + self.0.iter() + } + + /// Extract the sublines of the row. + /// + /// It is very unintuitive, but in current state of things, a `MathRow` can + /// contain several actual rows. That function deconstructs it to "single" + /// rows. Hopefully this is only a temporary hack. + pub fn rows(&self) -> Vec { + self.0 + .split(|frag| matches!(frag, MathFragment::Linebreak)) + .map(|slice| Self(slice.to_vec())) + .collect() + } + + pub fn ascent(&self) -> Abs { + self.iter().map(MathFragment::ascent).max().unwrap_or_default() + } + + pub fn descent(&self) -> Abs { + self.iter().map(MathFragment::descent).max().unwrap_or_default() + } + + pub fn class(&self) -> MathClass { + // Predict the class of the output of 'into_fragment' + if self.0.len() == 1 { + self.0 + .first() + .and_then(|fragment| fragment.class()) + .unwrap_or(MathClass::Special) + } else { + // FrameFragment::new() (inside 'into_fragment' in this branch) defaults + // to MathClass::Normal for its class. + MathClass::Normal + } + } + + pub fn into_frame(self, ctx: &MathContext) -> Frame { + let styles = ctx.styles(); + let align = AlignElem::alignment_in(styles).x.resolve(styles); + self.into_aligned_frame(ctx, &[], align) + } + + pub fn into_fragment(self, ctx: &MathContext) -> MathFragment { + if self.0.len() == 1 { + self.0.into_iter().next().unwrap() + } else { + FrameFragment::new(ctx, self.into_frame(ctx)).into() + } + } + + pub fn into_aligned_frame( + self, + ctx: &MathContext, + points: &[Abs], + align: Align, + ) -> Frame { + if self.iter().any(|frag| matches!(frag, MathFragment::Linebreak)) { + let leading = if ctx.style.size >= MathSize::Text { + ParElem::leading_in(ctx.styles()) + } else { + TIGHT_LEADING.scaled(ctx) + }; + + let mut rows: Vec<_> = self.rows(); + + if matches!(rows.last(), Some(row) if row.0.is_empty()) { + rows.pop(); + } + + let AlignmentResult { points, width } = alignments(&rows); + let mut frame = Frame::new(Size::zero()); + + for (i, row) in rows.into_iter().enumerate() { + let sub = row.into_line_frame(&points, align); + let size = frame.size_mut(); + if i > 0 { + size.y += leading; + } + + let mut pos = Point::with_y(size.y); + if points.is_empty() { + pos.x = align.position(width - sub.width()); + } + size.y += sub.height(); + size.x.set_max(sub.width()); + frame.push_frame(pos, sub); + } + frame + } else { + self.into_line_frame(points, align) + } + } + + fn into_line_frame(self, points: &[Abs], align: Align) -> Frame { + let ascent = self.ascent(); + let mut frame = Frame::new(Size::new(Abs::zero(), ascent + self.descent())); + frame.set_baseline(ascent); + + let mut next_x = { + let mut widths = Vec::new(); + if !points.is_empty() && align != Align::Left { + let mut width = Abs::zero(); + for fragment in self.iter() { + if matches!(fragment, MathFragment::Align) { + widths.push(width); + width = Abs::zero(); + } else { + width += fragment.width(); + } + } + widths.push(width); + } + let widths = widths; + + let mut prev_points = once(Abs::zero()).chain(points.iter().copied()); + let mut point_widths = points.iter().copied().zip(widths); + let mut alternator = LeftRightAlternator::Right; + move || match align { + Align::Left => prev_points.next(), + Align::Right => point_widths.next().map(|(point, width)| point - width), + _ => point_widths + .next() + .zip(prev_points.next()) + .zip(alternator.next()) + .map(|(((point, width), prev_point), alternator)| match alternator { + LeftRightAlternator::Left => prev_point, + LeftRightAlternator::Right => point - width, + }), + } + }; + let mut x = next_x().unwrap_or_default(); + + for fragment in self.0.into_iter() { + if matches!(fragment, MathFragment::Align) { + x = next_x().unwrap_or(x); + continue; + } + + let y = ascent - fragment.ascent(); + let pos = Point::new(x, y); + x += fragment.width(); + frame.push_frame(pos, fragment.into_frame()); + } + + frame.size_mut().x = x; + frame + } +} + +impl> From for MathRow { + fn from(fragment: T) -> Self { + Self(vec![fragment.into()]) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum LeftRightAlternator { + Left, + Right, +} + +impl Iterator for LeftRightAlternator { + type Item = LeftRightAlternator; + + fn next(&mut self) -> Option { + let r = Some(*self); + match self { + Self::Left => *self = Self::Right, + Self::Right => *self = Self::Left, + } + r + } +} diff --git a/crates/typst-library/src/math/spacing.rs b/crates/typst-library/src/math/spacing.rs new file mode 100644 index 00000000..848aca78 --- /dev/null +++ b/crates/typst-library/src/math/spacing.rs @@ -0,0 +1,60 @@ +use super::*; + +pub(super) const THIN: Em = Em::new(1.0 / 6.0); +pub(super) const MEDIUM: Em = Em::new(2.0 / 9.0); +pub(super) const THICK: Em = Em::new(5.0 / 18.0); +pub(super) const QUAD: Em = Em::new(1.0); + +/// Hook up all spacings. +pub(super) fn define(math: &mut Scope) { + math.define("thin", HElem::new(THIN.into()).pack()); + math.define("med", HElem::new(MEDIUM.into()).pack()); + math.define("thick", HElem::new(THICK.into()).pack()); + math.define("quad", HElem::new(QUAD.into()).pack()); +} + +/// Create the spacing between two fragments in a given style. +pub(super) fn spacing( + l: &MathFragment, + space: Option, + r: &MathFragment, +) -> Option { + use MathClass::*; + + let class = |f: &MathFragment| f.class().unwrap_or(Special); + let resolve = |v: Em, f: &MathFragment| { + Some(MathFragment::Spacing(f.font_size().map_or(Abs::zero(), |size| v.at(size)))) + }; + let script = + |f: &MathFragment| f.style().map_or(false, |s| s.size <= MathSize::Script); + + match (class(l), class(r)) { + // No spacing before punctuation; thin spacing after punctuation, unless + // in script size. + (_, Punctuation) => None, + (Punctuation, _) if !script(l) => resolve(THIN, l), + + // No spacing after opening delimiters and before closing delimiters. + (Opening, _) | (_, Closing) => None, + + // Thick spacing around relations, unless followed by a another relation + // or in script size. + (Relation, Relation) => None, + (Relation, _) if !script(l) => resolve(THICK, l), + (_, Relation) if !script(r) => resolve(THICK, r), + + // Medium spacing around binary operators, unless in script size. + (Binary, _) if !script(l) => resolve(MEDIUM, l), + (_, Binary) if !script(r) => resolve(MEDIUM, r), + + // Thin spacing around large operators, unless next to a delimiter. + (Large, Opening | Fence) | (Closing | Fence, Large) => None, + (Large, _) => resolve(THIN, l), + (_, Large) => resolve(THIN, r), + + // Spacing around spaced frames. + _ if (l.is_spaced() || r.is_spaced()) => space, + + _ => None, + } +} diff --git a/crates/typst-library/src/math/stretch.rs b/crates/typst-library/src/math/stretch.rs new file mode 100644 index 00000000..910f7a81 --- /dev/null +++ b/crates/typst-library/src/math/stretch.rs @@ -0,0 +1,199 @@ +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::LazyArray16; + +use super::*; + +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +impl GlyphFragment { + /// Try to stretch a glyph to a desired height. + pub fn stretch_vertical( + self, + ctx: &MathContext, + height: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, height, short_fall, false) + } + + /// Try to stretch a glyph to a desired width. + pub fn stretch_horizontal( + self, + ctx: &MathContext, + width: Abs, + short_fall: Abs, + ) -> VariantFragment { + stretch_glyph(ctx, self, width, short_fall, true) + } +} + +/// Try to stretch a glyph to a desired width or height. +/// +/// The resulting frame may not have the exact desired width. +fn stretch_glyph( + ctx: &MathContext, + mut base: GlyphFragment, + target: Abs, + short_fall: Abs, + horizontal: bool, +) -> VariantFragment { + let short_target = target - short_fall; + let mut min_overlap = Abs::zero(); + let construction = ctx + .table + .variants + .and_then(|variants| { + min_overlap = variants.min_connector_overlap.scaled(ctx); + if horizontal { + variants.horizontal_constructions + } else { + variants.vertical_constructions + } + .get(base.id) + }) + .unwrap_or(GlyphConstruction { assembly: None, variants: LazyArray16::new(&[]) }); + + // If the base glyph is good enough, use it. + let advance = if horizontal { base.width } else { base.height() }; + if short_target <= advance { + return base.into_variant(); + } + + // Search for a pre-made variant with a good advance. + let mut best_id = base.id; + let mut best_advance = base.width; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = base.font.to_em(variant.advance_measurement).at(base.font_size); + if short_target <= best_advance { + break; + } + } + + // This is either good or the best we've got. + if short_target <= best_advance || construction.assembly.is_none() { + base.set_id(ctx, best_id); + return base.into_variant(); + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + assemble(ctx, base, assembly, min_overlap, target, horizontal) +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &MathContext, + base: GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + horizontal: bool, +) -> VariantFragment { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx); + if let Some(next) = parts.peek() { + let max_overlap = part + .end_connector_length + .min(next.start_connector_length) + .scaled(ctx); + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut selected = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = part.full_advance.scaled(ctx); + if let Some(next) = parts.peek() { + let max_overlap = + part.end_connector_length.min(next.start_connector_length).scaled(ctx); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + + let mut fragment = base.clone(); + fragment.set_id(ctx, part.glyph_id); + selected.push((fragment, advance)); + } + + let size; + let baseline; + if horizontal { + let height = base.ascent + base.descent; + size = Size::new(full, height); + baseline = base.ascent; + } else { + let axis = scaled!(ctx, axis_height); + let width = selected.iter().map(|(f, _)| f.width).max().unwrap_or_default(); + size = Size::new(width, full); + baseline = full / 2.0 + axis; + } + + let mut frame = Frame::new(size); + let mut offset = Abs::zero(); + frame.set_baseline(baseline); + frame.meta_iter(base.meta); + + for (fragment, advance) in selected { + let pos = if horizontal { + Point::new(offset, frame.baseline() - fragment.ascent) + } else { + Point::with_y(full - offset - fragment.height()) + }; + frame.push_frame(pos, fragment.into_frame()); + offset += advance; + } + + VariantFragment { + c: base.c, + id: None, + frame, + style: base.style, + font_size: base.font_size, + italics_correction: Abs::zero(), + class: base.class, + span: base.span, + limits: base.limits, + } +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat(part).take(count) + }) +} diff --git a/crates/typst-library/src/math/style.rs b/crates/typst-library/src/math/style.rs new file mode 100644 index 00000000..235770db --- /dev/null +++ b/crates/typst-library/src/math/style.rs @@ -0,0 +1,620 @@ +use super::*; + +/// Bold font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ bold(A) := B^+ $ +/// ``` +/// +/// Display: Bold +/// Category: math +#[func] +pub fn bold( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_bold(Some(true)).pack() +} + +/// Upright (non-italic) font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ upright(A) != A $ +/// ``` +/// +/// Display: Upright +/// Category: math +#[func] +pub fn upright( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_italic(Some(false)).pack() +} + +/// Italic font style in math. +/// +/// For roman letters and greek lowercase letters, this is already the default. +/// +/// Display: Italic +/// Category: math +#[func] +pub fn italic( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_italic(Some(true)).pack() +} +/// Serif (roman) font style in math. +/// +/// This is already the default. +/// +/// Display: Serif +/// Category: math +#[func] +pub fn serif( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Serif)).pack() +} + +/// Sans-serif font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ sans(A B C) $ +/// ``` +/// +/// Display: Sans-serif +/// Category: math +#[func] +pub fn sans( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Sans)).pack() +} + +/// Calligraphic font style in math. +/// +/// ## Example { #example } +/// ```example +/// Let $cal(P)$ be the set of ... +/// ``` +/// +/// Display: Calligraphic +/// Category: math +#[func] +pub fn cal( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Cal)).pack() +} + +/// Fraktur font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ frak(P) $ +/// ``` +/// +/// Display: Fraktur +/// Category: math +#[func] +pub fn frak( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Frak)).pack() +} + +/// Monospace font style in math. +/// +/// ## Example { #example } +/// ```example +/// $ mono(x + y = z) $ +/// ``` +/// +/// Display: Monospace +/// Category: math +#[func] +pub fn mono( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Mono)).pack() +} + +/// Blackboard bold (double-struck) font style in math. +/// +/// For uppercase latin letters, blackboard bold is additionally available +/// through [symbols]($category/symbols/sym) of the form `NN` and `RR`. +/// +/// ## Example { #example } +/// ```example +/// $ bb(b) $ +/// $ bb(N) = NN $ +/// $ f: NN -> RR $ +/// ``` +/// +/// Display: Blackboard Bold +/// Category: math +#[func] +pub fn bb( + /// The content to style. + body: Content, +) -> Content { + MathStyleElem::new(body).with_variant(Some(MathVariant::Bb)).pack() +} + +/// Forced display style in math. +/// +/// This is the normal size for block equations. +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = display(sum_i x_i/2)$ +/// ``` +/// +/// Display: Display Size +/// Category: math +#[func] +pub fn display( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(false)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Display)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced inline (text) style in math. +/// +/// This is the normal size for inline equations. +/// +/// ## Example { #example } +/// ```example +/// $ sum_i x_i/2 +/// = inline(sum_i x_i/2) $ +/// ``` +/// +/// Display: Inline Size +/// Category: math +#[func] +pub fn inline( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(false)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Text)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced script style in math. +/// +/// This is the smaller size used in powers or sub- or superscripts. +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = script(sum_i x_i/2)$ +/// ``` +/// +/// Display: Script Size +/// Category: math +#[func] +pub fn script( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(true)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::Script)) + .with_cramped(Some(cramped)) + .pack() +} + +/// Forced second script style in math. +/// +/// This is the smallest size, used in second-level sub- and superscripts +/// (script of the script). +/// +/// ## Example { #example } +/// ```example +/// $sum_i x_i/2 = sscript(sum_i x_i/2)$ +/// ``` +/// +/// Display: Script-Script Size +/// Category: math +#[func] +pub fn sscript( + /// The content to size. + body: Content, + /// Whether to impose a height restriction for exponents, like regular sub- + /// and superscripts do. + #[named] + #[default(true)] + cramped: bool, +) -> Content { + MathStyleElem::new(body) + .with_size(Some(MathSize::ScriptScript)) + .with_cramped(Some(cramped)) + .pack() +} + +/// A font variant in math. +/// +/// Display: Bold +/// Category: math +#[element(LayoutMath)] +pub struct MathStyleElem { + /// The content to style. + #[required] + pub body: Content, + + /// The variant to select. + pub variant: Option, + + /// Whether to use bold glyphs. + pub bold: Option, + + /// Whether to use italic glyphs. + pub italic: Option, + + /// Whether to use forced size + pub size: Option, + + /// Whether to limit height of exponents + pub cramped: Option, +} + +impl LayoutMath for MathStyleElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + let mut style = ctx.style; + if let Some(variant) = self.variant(StyleChain::default()) { + style = style.with_variant(variant); + } + if let Some(bold) = self.bold(StyleChain::default()) { + style = style.with_bold(bold); + } + if let Some(italic) = self.italic(StyleChain::default()) { + style = style.with_italic(italic); + } + if let Some(size) = self.size(StyleChain::default()) { + style = style.with_size(size); + } + if let Some(cramped) = self.cramped(StyleChain::default()) { + style = style.with_cramped(cramped); + } + ctx.style(style); + self.body().layout_math(ctx)?; + ctx.unstyle(); + Ok(()) + } +} + +/// Text properties in math. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct MathStyle { + /// The style variant to select. + pub variant: MathVariant, + /// The size of the glyphs. + pub size: MathSize, + /// Affects the height of exponents. + pub cramped: bool, + /// Whether to use bold glyphs. + pub bold: bool, + /// Whether to use italic glyphs. + pub italic: Smart, +} + +impl MathStyle { + /// This style, with the given `variant`. + pub fn with_variant(self, variant: MathVariant) -> Self { + Self { variant, ..self } + } + + /// This style, with the given `size`. + pub fn with_size(self, size: MathSize) -> Self { + Self { size, ..self } + } + + /// This style, with `cramped` set to the given value. + pub fn with_cramped(self, cramped: bool) -> Self { + Self { cramped, ..self } + } + + /// This style, with `bold` set to the given value. + pub fn with_bold(self, bold: bool) -> Self { + Self { bold, ..self } + } + + /// This style, with `italic` set to the given value. + pub fn with_italic(self, italic: bool) -> Self { + Self { italic: Smart::Custom(italic), ..self } + } + + /// The style for subscripts in the current style. + pub fn for_subscript(self) -> Self { + self.for_superscript().with_cramped(true) + } + + /// The style for superscripts in the current style. + pub fn for_superscript(self) -> Self { + self.with_size(match self.size { + MathSize::Display | MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + } + + /// The style for numerators in the current style. + pub fn for_numerator(self) -> Self { + self.with_size(match self.size { + MathSize::Display => MathSize::Text, + MathSize::Text => MathSize::Script, + MathSize::Script | MathSize::ScriptScript => MathSize::ScriptScript, + }) + } + + /// The style for denominators in the current style. + pub fn for_denominator(self) -> Self { + self.for_numerator().with_cramped(true) + } + + /// Apply the style to a character. + pub fn styled_char(self, c: char) -> char { + styled_char(self, c) + } +} + +/// The size of elements in an equation. +/// +/// See the TeXbook p. 141. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Cast)] +pub enum MathSize { + /// Second-level sub- and superscripts. + ScriptScript, + /// Sub- and superscripts. + Script, + /// Math in text. + Text, + /// Math on its own line. + Display, +} + +impl MathSize { + pub(super) fn factor(self, ctx: &MathContext) -> f64 { + match self { + Self::Display | Self::Text => 1.0, + Self::Script => percent!(ctx, script_percent_scale_down), + Self::ScriptScript => percent!(ctx, script_script_percent_scale_down), + } + } +} + +/// A mathematical style variant, as defined by Unicode. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Cast)] +pub enum MathVariant { + Serif, + Sans, + Cal, + Frak, + Mono, + Bb, +} + +impl Default for MathVariant { + fn default() -> Self { + Self::Serif + } +} + +/// Select the correct styled math letter. +/// +/// https://www.w3.org/TR/mathml-core/#new-text-transform-mappings +/// https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols +pub(super) fn styled_char(style: MathStyle, c: char) -> char { + use MathVariant::*; + + let MathStyle { variant, bold, .. } = style; + let italic = style.italic.unwrap_or(matches!( + c, + 'a'..='z' | 'ı' | 'ȷ' | 'A'..='Z' | 'α'..='ω' | + '∂' | 'ϵ' | 'ϑ' | 'ϰ' | 'ϕ' | 'ϱ' | 'ϖ' + )); + + if let Some(c) = basic_exception(c) { + return c; + } + + if let Some(c) = latin_exception(c, variant, bold, italic) { + return c; + } + + if let Some(c) = greek_exception(c, variant, bold, italic) { + return c; + } + + let base = match c { + 'A'..='Z' => 'A', + 'a'..='z' => 'a', + 'Α'..='Ω' => 'Α', + 'α'..='ω' => 'α', + '0'..='9' => '0', + _ => return c, + }; + + let tuple = (variant, bold, italic); + let start = match c { + // Latin upper. + 'A'..='Z' => match tuple { + (Serif, false, false) => 0x0041, + (Serif, true, false) => 0x1D400, + (Serif, false, true) => 0x1D434, + (Serif, true, true) => 0x1D468, + (Sans, false, false) => 0x1D5A0, + (Sans, true, false) => 0x1D5D4, + (Sans, false, true) => 0x1D608, + (Sans, true, true) => 0x1D63C, + (Cal, false, _) => 0x1D49C, + (Cal, true, _) => 0x1D4D0, + (Frak, false, _) => 0x1D504, + (Frak, true, _) => 0x1D56C, + (Mono, _, _) => 0x1D670, + (Bb, _, _) => 0x1D538, + }, + + // Latin lower. + 'a'..='z' => match tuple { + (Serif, false, false) => 0x0061, + (Serif, true, false) => 0x1D41A, + (Serif, false, true) => 0x1D44E, + (Serif, true, true) => 0x1D482, + (Sans, false, false) => 0x1D5BA, + (Sans, true, false) => 0x1D5EE, + (Sans, false, true) => 0x1D622, + (Sans, true, true) => 0x1D656, + (Cal, false, _) => 0x1D4B6, + (Cal, true, _) => 0x1D4EA, + (Frak, false, _) => 0x1D51E, + (Frak, true, _) => 0x1D586, + (Mono, _, _) => 0x1D68A, + (Bb, _, _) => 0x1D552, + }, + + // Greek upper. + 'Α'..='Ω' => match tuple { + (Serif, false, false) => 0x0391, + (Serif, true, false) => 0x1D6A8, + (Serif, false, true) => 0x1D6E2, + (Serif, true, true) => 0x1D71C, + (Sans, _, false) => 0x1D756, + (Sans, _, true) => 0x1D790, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Greek lower. + 'α'..='ω' => match tuple { + (Serif, false, false) => 0x03B1, + (Serif, true, false) => 0x1D6C2, + (Serif, false, true) => 0x1D6FC, + (Serif, true, true) => 0x1D736, + (Sans, _, false) => 0x1D770, + (Sans, _, true) => 0x1D7AA, + (Cal | Frak | Mono | Bb, _, _) => return c, + }, + + // Numbers. + '0'..='9' => match tuple { + (Serif, false, _) => 0x0030, + (Serif, true, _) => 0x1D7CE, + (Bb, _, _) => 0x1D7D8, + (Sans, false, _) => 0x1D7E2, + (Sans, true, _) => 0x1D7EC, + (Mono, _, _) => 0x1D7F6, + (Cal | Frak, _, _) => return c, + }, + + _ => unreachable!(), + }; + + std::char::from_u32(start + (c as u32 - base as u32)).unwrap() +} + +fn basic_exception(c: char) -> Option { + Some(match c { + '〈' => '⟨', + '〉' => '⟩', + '《' => '⟪', + '》' => '⟫', + _ => return None, + }) +} + +fn latin_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option { + use MathVariant::*; + Some(match (c, variant, bold, italic) { + ('B', Cal, false, _) => 'ℬ', + ('E', Cal, false, _) => 'ℰ', + ('F', Cal, false, _) => 'ℱ', + ('H', Cal, false, _) => 'ℋ', + ('I', Cal, false, _) => 'ℐ', + ('L', Cal, false, _) => 'ℒ', + ('M', Cal, false, _) => 'ℳ', + ('R', Cal, false, _) => 'ℛ', + ('C', Frak, false, _) => 'ℭ', + ('H', Frak, false, _) => 'ℌ', + ('I', Frak, false, _) => 'ℑ', + ('R', Frak, false, _) => 'ℜ', + ('Z', Frak, false, _) => 'ℨ', + ('C', Bb, ..) => 'ℂ', + ('H', Bb, ..) => 'ℍ', + ('N', Bb, ..) => 'ℕ', + ('P', Bb, ..) => 'ℙ', + ('Q', Bb, ..) => 'ℚ', + ('R', Bb, ..) => 'ℝ', + ('Z', Bb, ..) => 'ℤ', + ('h', Serif, false, true) => 'ℎ', + ('e', Cal, false, _) => 'ℯ', + ('g', Cal, false, _) => 'ℊ', + ('o', Cal, false, _) => 'ℴ', + ('ı', Serif, .., true) => '𝚤', + ('ȷ', Serif, .., true) => '𝚥', + _ => return None, + }) +} + +fn greek_exception( + c: char, + variant: MathVariant, + bold: bool, + italic: bool, +) -> Option { + use MathVariant::*; + let list = match c { + 'ϴ' => ['𝚹', '𝛳', '𝜭', '𝝧', '𝞡'], + '∇' => ['𝛁', '𝛻', '𝜵', '𝝯', '𝞩'], + '∂' => ['𝛛', '𝜕', '𝝏', '𝞉', '𝟃'], + 'ϵ' => ['𝛜', '𝜖', '𝝐', '𝞊', '𝟄'], + 'ϑ' => ['𝛝', '𝜗', '𝝑', '𝞋', '𝟅'], + 'ϰ' => ['𝛞', '𝜘', '𝝒', '𝞌', '𝟆'], + 'ϕ' => ['𝛟', '𝜙', '𝝓', '𝞍', '𝟇'], + 'ϱ' => ['𝛠', '𝜚', '𝝔', '𝞎', '𝟈'], + 'ϖ' => ['𝛡', '𝜛', '𝝕', '𝞏', '𝟉'], + _ => return None, + }; + + Some(match (variant, bold, italic) { + (Serif, true, false) => list[0], + (Serif, false, true) => list[1], + (Serif, true, true) => list[2], + (Sans, _, false) => list[3], + (Sans, _, true) => list[4], + _ => return None, + }) +} diff --git a/crates/typst-library/src/math/underover.rs b/crates/typst-library/src/math/underover.rs new file mode 100644 index 00000000..796c9ebc --- /dev/null +++ b/crates/typst-library/src/math/underover.rs @@ -0,0 +1,339 @@ +use super::*; + +const BRACE_GAP: Em = Em::new(0.25); +const BRACKET_GAP: Em = Em::new(0.25); + +/// A marker to distinguish under- vs. overlines. +enum LineKind { + Over, + Under, +} + +/// A horizontal line under content. +/// +/// ## Example { #example } +/// ```example +/// $ underline(1 + 2 + ... + 5) $ +/// ``` +/// +/// Display: Underline +/// Category: math +#[element(LayoutMath)] +pub struct UnderlineElem { + /// The content above the line. + #[required] + pub body: Content, +} + +impl LayoutMath for UnderlineElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverline(ctx, &self.body(), self.span(), LineKind::Under) + } +} + +/// A horizontal line over content. +/// +/// ## Example { #example } +/// ```example +/// $ overline(1 + 2 + ... + 5) $ +/// ``` +/// +/// Display: Overline +/// Category: math +#[element(LayoutMath)] +pub struct OverlineElem { + /// The content below the line. + #[required] + pub body: Content, +} + +impl LayoutMath for OverlineElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverline(ctx, &self.body(), self.span(), LineKind::Over) + } +} + +/// layout under- or overlined content +fn layout_underoverline( + ctx: &mut MathContext, + body: &Content, + span: Span, + line: LineKind, +) -> SourceResult<()> { + let (extra_height, content, line_pos, content_pos, baseline, bar_height); + match line { + LineKind::Under => { + let sep = scaled!(ctx, underbar_extra_descender); + bar_height = scaled!(ctx, underbar_rule_thickness); + let gap = scaled!(ctx, underbar_vertical_gap); + extra_height = sep + bar_height + gap; + + content = ctx.layout_fragment(body)?; + + line_pos = Point::with_y(content.height() + gap + bar_height / 2.0); + content_pos = Point::zero(); + baseline = content.ascent() + } + LineKind::Over => { + let sep = scaled!(ctx, overbar_extra_ascender); + bar_height = scaled!(ctx, overbar_rule_thickness); + let gap = scaled!(ctx, overbar_vertical_gap); + extra_height = sep + bar_height + gap; + + ctx.style(ctx.style.with_cramped(true)); + content = ctx.layout_fragment(body)?; + ctx.unstyle(); + + line_pos = Point::with_y(sep + bar_height / 2.0); + content_pos = Point::with_y(extra_height); + baseline = content.ascent() + extra_height; + } + } + + let width = content.width(); + let height = content.height() + extra_height; + let size = Size::new(width, height); + + let content_class = content.class().unwrap_or(MathClass::Normal); + let mut frame = Frame::new(size); + frame.set_baseline(baseline); + frame.push_frame(content_pos, content.into_frame()); + frame.push( + line_pos, + FrameItem::Shape( + Geometry::Line(Point::with_x(width)).stroked(Stroke { + paint: TextElem::fill_in(ctx.styles()), + thickness: bar_height, + ..Stroke::default() + }), + span, + ), + ); + + ctx.push(FrameFragment::new(ctx, frame).with_class(content_class)); + + Ok(()) +} + +/// A horizontal brace under content, with an optional annotation below. +/// +/// ## Example { #example } +/// ```example +/// $ underbrace(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Underbrace +/// Category: math +#[element(LayoutMath)] +pub struct UnderbraceElem { + /// The content above the brace. + #[required] + pub body: Content, + + /// The optional content below the brace. + #[positional] + pub annotation: Option, +} + +impl LayoutMath for UnderbraceElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⏟', + BRACE_GAP, + false, + self.span(), + ) + } +} + +/// A horizontal brace over content, with an optional annotation above. +/// +/// ## Example { #example } +/// ```example +/// $ overbrace(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Overbrace +/// Category: math +#[element(LayoutMath)] +pub struct OverbraceElem { + /// The content below the brace. + #[required] + pub body: Content, + + /// The optional content above the brace. + #[positional] + pub annotation: Option, +} + +impl LayoutMath for OverbraceElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⏞', + BRACE_GAP, + true, + self.span(), + ) + } +} + +/// A horizontal bracket under content, with an optional annotation below. +/// +/// ## Example { #example } +/// ```example +/// $ underbracket(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Underbracket +/// Category: math +#[element(LayoutMath)] +pub struct UnderbracketElem { + /// The content above the bracket. + #[required] + pub body: Content, + + /// The optional content below the bracket. + #[positional] + pub annotation: Option, +} + +impl LayoutMath for UnderbracketElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⎵', + BRACKET_GAP, + false, + self.span(), + ) + } +} + +/// A horizontal bracket over content, with an optional annotation above. +/// +/// ## Example { #example } +/// ```example +/// $ overbracket(1 + 2 + ... + 5, "numbers") $ +/// ``` +/// +/// Display: Overbracket +/// Category: math +#[element(LayoutMath)] +pub struct OverbracketElem { + /// The content below the bracket. + #[required] + pub body: Content, + + /// The optional content above the bracket. + #[positional] + pub annotation: Option, +} + +impl LayoutMath for OverbracketElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + layout_underoverspreader( + ctx, + &self.body(), + &self.annotation(ctx.styles()), + '⎴', + BRACKET_GAP, + true, + self.span(), + ) + } +} + +/// Layout an over- or underbrace-like object. +fn layout_underoverspreader( + ctx: &mut MathContext, + body: &Content, + annotation: &Option, + c: char, + gap: Em, + reverse: bool, + span: Span, +) -> SourceResult<()> { + let gap = gap.scaled(ctx); + let body = ctx.layout_row(body)?; + let body_class = body.class(); + let body = body.into_fragment(ctx); + let glyph = GlyphFragment::new(ctx, c, span); + let stretched = glyph.stretch_horizontal(ctx, body.width(), Abs::zero()); + + let mut rows = vec![MathRow::new(vec![body]), stretched.into()]; + ctx.style(if reverse { + ctx.style.for_subscript() + } else { + ctx.style.for_superscript() + }); + rows.extend( + annotation + .as_ref() + .map(|annotation| ctx.layout_row(annotation)) + .transpose()?, + ); + ctx.unstyle(); + + let mut baseline = 0; + if reverse { + rows.reverse(); + baseline = rows.len() - 1; + } + + let frame = stack(ctx, rows, Align::Center, gap, baseline); + ctx.push(FrameFragment::new(ctx, frame).with_class(body_class)); + + Ok(()) +} + +/// Stack rows on top of each other. +/// +/// Add a `gap` between each row and uses the baseline of the `baseline`th +/// row for the whole frame. +pub(super) fn stack( + ctx: &MathContext, + rows: Vec, + align: Align, + gap: Abs, + baseline: usize, +) -> Frame { + let rows: Vec<_> = rows.into_iter().flat_map(|r| r.rows()).collect(); + let AlignmentResult { points, width } = alignments(&rows); + let rows: Vec<_> = rows + .into_iter() + .map(|row| row.into_aligned_frame(ctx, &points, align)) + .collect(); + + let mut y = Abs::zero(); + let mut frame = Frame::new(Size::new( + width, + rows.iter().map(|row| row.height()).sum::() + + rows.len().saturating_sub(1) as f64 * gap, + )); + + for (i, row) in rows.into_iter().enumerate() { + let x = align.position(width - row.width()); + let pos = Point::new(x, y); + if i == baseline { + frame.set_baseline(y + row.baseline()); + } + y += row.height() + gap; + frame.push_frame(pos, row); + } + + frame +} diff --git a/crates/typst-library/src/meta/bibliography.rs b/crates/typst-library/src/meta/bibliography.rs new file mode 100644 index 00000000..0531997d --- /dev/null +++ b/crates/typst-library/src/meta/bibliography.rs @@ -0,0 +1,724 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::Arc; + +use ecow::{eco_vec, EcoVec}; +use hayagriva::io::{BibLaTeXError, YamlBibliographyError}; +use hayagriva::style::{self, Brackets, Citation, Database, DisplayString, Formatting}; +use hayagriva::Entry; +use typst::diag::FileError; +use typst::util::{option_eq, Bytes}; + +use super::{LinkElem, LocalName, RefElem}; +use crate::layout::{BlockElem, GridElem, ParElem, Sizing, TrackSizings, VElem}; +use crate::meta::{FootnoteElem, HeadingElem}; +use crate::prelude::*; +use crate::text::TextElem; + +/// A bibliography / reference listing. +/// +/// You can create a new bibliography by calling this function with a path +/// to a bibliography file in either one of two formats: +/// +/// - A Hayagriva `.yml` file. Hayagriva is a new bibliography file format +/// designed for use with Typst. Visit its +/// [documentation](https://github.com/typst/hayagriva/blob/main/docs/file-format.md) +/// for more details. +/// - A BibLaTeX `.bib` file. +/// +/// As soon as you add a bibliography somewhere in your document, you can start +/// citing things with reference syntax (`[@key]`) or explicit calls to the +/// [citation]($func/cite) function (`[#cite("key")]`). The bibliography will +/// only show entries for works that were referenced in the document. +/// +/// # Example +/// ```example +/// This was already noted by +/// pirates long ago. @arrgh +/// +/// Multiple sources say ... +/// #cite("arrgh", "netwok"). +/// +/// #bibliography("works.bib") +/// ``` +/// +/// Display: Bibliography +/// Category: meta +#[element(Locatable, Synthesize, Show, Finalize, LocalName)] +pub struct BibliographyElem { + /// Path to a Hayagriva `.yml` or BibLaTeX `.bib` file. + #[required] + #[parse( + let Spanned { v: paths, span } = + args.expect::>("path to bibliography file")?; + + // Load bibliography files. + let data = paths.0 + .iter() + .map(|path| { + let id = vm.location().join(path).at(span)?; + vm.world().file(id).at(span) + }) + .collect::>>()?; + + // Check that parsing works. + let _ = load(&paths, &data).at(span)?; + + paths + )] + pub path: BibPaths, + + /// The raw file buffers. + #[internal] + #[required] + #[parse(data)] + pub data: Vec, + + /// The title of the bibliography. + /// + /// - When set to `{auto}`, an appropriate title for the [text + /// language]($func/text.lang) will be used. This is the default. + /// - When set to `{none}`, the bibliography will not have a title. + /// - A custom title can be set by passing content. + /// + /// The bibliography's heading will not be numbered by default, but you can + /// force it to be with a show-set rule: + /// `{show bibliography: set heading(numbering: "1.")}` + /// ``` + #[default(Some(Smart::Auto))] + pub title: Option>, + + /// The bibliography style. + #[default(BibliographyStyle::Ieee)] + pub style: BibliographyStyle, +} + +/// A list of bibliography file paths. +#[derive(Debug, Default, Clone, Hash)] +pub struct BibPaths(Vec); + +cast! { + BibPaths, + self => self.0.into_value(), + v: EcoString => Self(vec![v]), + v: Array => Self(v.into_iter().map(Value::cast).collect::>()?), +} + +impl BibliographyElem { + /// Find the document's bibliography. + pub fn find(introspector: Tracked) -> StrResult { + let mut iter = introspector.query(&Self::func().select()).into_iter(); + let Some(elem) = iter.next() else { + bail!("the document does not contain a bibliography"); + }; + + if iter.next().is_some() { + bail!("multiple bibliographies are not supported"); + } + + Ok(elem.to::().unwrap().clone()) + } + + /// Whether the bibliography contains the given key. + pub fn has(vt: &Vt, key: &str) -> bool { + vt.introspector + .query(&Self::func().select()) + .into_iter() + .flat_map(|elem| { + let elem = elem.to::().unwrap(); + load(&elem.path(), &elem.data()) + }) + .flatten() + .any(|entry| entry.key() == key) + } + + /// Find all bibliography keys. + pub fn keys( + introspector: Tracked, + ) -> Vec<(EcoString, Option)> { + Self::find(introspector) + .and_then(|elem| load(&elem.path(), &elem.data())) + .into_iter() + .flatten() + .map(|entry| { + let key = entry.key().into(); + let detail = + entry.title().map(|title| title.canonical.value.as_str().into()); + (key, detail) + }) + .collect() + } +} + +impl Synthesize for BibliographyElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_style(self.style(styles)); + Ok(()) + } +} + +impl Show for BibliographyElem { + #[tracing::instrument(name = "BibliographyElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + const COLUMN_GUTTER: Em = Em::new(0.65); + const INDENT: Em = Em::new(1.5); + + let mut seq = vec![]; + if let Some(title) = self.title(styles) { + let title = + title.unwrap_or_else(|| { + TextElem::packed(self.local_name( + TextElem::lang_in(styles), + TextElem::region_in(styles), + )) + .spanned(self.span()) + }); + + seq.push(HeadingElem::new(title).with_level(NonZeroUsize::ONE).pack()); + } + + Ok(vt.delayed(|vt| { + let works = Works::new(vt).at(self.span())?; + + let row_gutter = BlockElem::below_in(styles).amount(); + if works.references.iter().any(|(prefix, _)| prefix.is_some()) { + let mut cells = vec![]; + for (prefix, reference) in &works.references { + cells.push(prefix.clone().unwrap_or_default()); + cells.push(reference.clone()); + } + + seq.push(VElem::new(row_gutter).with_weakness(3).pack()); + seq.push( + GridElem::new(cells) + .with_columns(TrackSizings(vec![Sizing::Auto; 2])) + .with_column_gutter(TrackSizings(vec![COLUMN_GUTTER.into()])) + .with_row_gutter(TrackSizings(vec![row_gutter.into()])) + .pack(), + ); + } else { + let mut entries = vec![]; + for (_, reference) in &works.references { + entries.push(VElem::new(row_gutter).with_weakness(3).pack()); + entries.push(reference.clone()); + } + + seq.push( + Content::sequence(entries) + .styled(ParElem::set_hanging_indent(INDENT.into())), + ); + } + + Ok(Content::sequence(seq)) + })) + } +} + +impl Finalize for BibliographyElem { + fn finalize(&self, realized: Content, _: StyleChain) -> Content { + realized.styled(HeadingElem::set_numbering(None)) + } +} + +impl LocalName for BibliographyElem { + fn local_name(&self, lang: Lang, region: Option) -> &'static str { + match lang { + Lang::ALBANIAN => "Bibliografi", + Lang::ARABIC => "المراجع", + Lang::BOKMÅL => "Bibliografi", + Lang::CHINESE if option_eq(region, "TW") => "書目", + Lang::CHINESE => "参考文献", + Lang::CZECH => "Bibliografie", + Lang::DANISH => "Bibliografi", + Lang::DUTCH => "Bibliografie", + Lang::FILIPINO => "Bibliograpiya", + Lang::FRENCH => "Bibliographie", + Lang::GERMAN => "Bibliographie", + Lang::ITALIAN => "Bibliografia", + Lang::NYNORSK => "Bibliografi", + Lang::POLISH => "Bibliografia", + Lang::PORTUGUESE => "Bibliografia", + Lang::RUSSIAN => "Библиография", + Lang::SLOVENIAN => "Literatura", + Lang::SPANISH => "Bibliografía", + Lang::SWEDISH => "Bibliografi", + Lang::TURKISH => "Kaynakça", + Lang::UKRAINIAN => "Бібліографія", + Lang::VIETNAMESE => "Tài liệu tham khảo", + Lang::ENGLISH | _ => "Bibliography", + } + } +} + +/// A bibliography style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum BibliographyStyle { + /// Follows guidance of the American Psychological Association. Based on the + /// 7th edition of the APA Publication Manual. + Apa, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + ChicagoAuthorDate, + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, + /// The style of the Institute of Electrical and Electronics Engineers. + /// Based on the 2018 IEEE Reference Guide. + Ieee, + /// Follows guidance of the Modern Language Association. Based on the 8th + /// edition of the MLA Handbook. + Mla, +} + +impl BibliographyStyle { + /// The default citation style for this bibliography style. + pub fn default_citation_style(self) -> CitationStyle { + match self { + Self::Apa => CitationStyle::ChicagoAuthorDate, + Self::ChicagoAuthorDate => CitationStyle::ChicagoAuthorDate, + Self::ChicagoNotes => CitationStyle::ChicagoNotes, + Self::Ieee => CitationStyle::Numerical, + Self::Mla => CitationStyle::ChicagoAuthorDate, + } + } +} + +/// Cite a work from the bibliography. +/// +/// Before you starting citing, you need to add a +/// [bibliography]($func/bibliography) somewhere in your document. +/// +/// # Example +/// ```example +/// This was already noted by +/// pirates long ago. @arrgh +/// +/// Multiple sources say ... +/// #cite("arrgh", "netwok"). +/// +/// #bibliography("works.bib") +/// ``` +/// +/// # Syntax +/// This function indirectly has dedicated syntax. [References]($func/ref) +/// can be used to cite works from the bibliography. The label then +/// corresponds to the citation key. +/// +/// Display: Citation +/// Category: meta +#[element(Locatable, Synthesize, Show)] +pub struct CiteElem { + /// The citation keys that identify the elements that shall be cited in + /// the bibliography. + /// + /// Reference syntax supports only a single key. + #[variadic] + pub keys: Vec, + + /// A supplement for the citation such as page or chapter number. + /// + /// In reference syntax, the supplement can be added in square brackets: + /// + /// ```example + /// This has been proven over and + /// over again. @distress[p.~7] + /// + /// #bibliography("works.bib") + /// ``` + #[positional] + pub supplement: Option, + + /// Whether the citation should include brackets. + /// + /// ```example + /// #set cite(brackets: false) + /// + /// @netwok follow these methods + /// in their work ... + /// + /// #bibliography( + /// "works.bib", + /// style: "chicago-author-date", + /// ) + /// ``` + #[default(true)] + pub brackets: bool, + + /// The citation style. + /// + /// When set to `{auto}`, automatically picks the preferred citation style + /// for the bibliography's style. + /// + /// ```example + /// #set cite(style: "alphanumerical") + /// Alphanumerical references. + /// @netwok + /// + /// #bibliography("works.bib") + /// ``` + pub style: Smart, +} + +impl Synthesize for CiteElem { + fn synthesize(&mut self, _vt: &mut Vt, styles: StyleChain) -> SourceResult<()> { + self.push_supplement(self.supplement(styles)); + self.push_brackets(self.brackets(styles)); + self.push_style(self.style(styles)); + Ok(()) + } +} + +impl Show for CiteElem { + #[tracing::instrument(name = "CiteElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult { + Ok(vt.delayed(|vt| { + let works = Works::new(vt).at(self.span())?; + let location = self.0.location().unwrap(); + works + .citations + .get(&location) + .cloned() + .flatten() + .ok_or("bibliography does not contain this key") + .at(self.span()) + })) + } +} + +cast! { + CiteElem, + v: Content => v.to::().cloned().ok_or("expected citation")?, +} + +/// A citation style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum CitationStyle { + /// IEEE-style numerical reference markers. + Numerical, + /// A simple alphanumerical style. For example, the output could be Rass97 + /// or MKG+21. + Alphanumerical, + /// Citations that just consist of the entry keys. + Keys, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + ChicagoAuthorDate, + /// The Chicago Notes style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 14. + ChicagoNotes, + /// A Chicago-like author-title format. Results could look like this: + /// Prokopov, “It Is Fast or It Is Wrong”. + ChicagoAuthorTitle, +} + +impl CitationStyle { + fn is_short(self) -> bool { + matches!(self, Self::Numerical | Self::Alphanumerical | Self::Keys) + } +} + +/// Fully formatted citations and references. +#[derive(Default)] +struct Works { + citations: HashMap>, + references: Vec<(Option, Content)>, +} + +impl Works { + /// Prepare all things need to cite a work or format a bibliography. + fn new(vt: &Vt) -> StrResult> { + let bibliography = BibliographyElem::find(vt.introspector)?; + let citations = vt + .introspector + .query(&Selector::Or(eco_vec![ + RefElem::func().select(), + CiteElem::func().select(), + ])) + .into_iter() + .map(|elem| match elem.to::() { + Some(reference) => reference.citation().unwrap(), + _ => elem.to::().unwrap().clone(), + }) + .collect(); + Ok(create(bibliography, citations)) + } +} + +/// Generate all citations and the whole bibliography. +#[comemo::memoize] +fn create(bibliography: BibliographyElem, citations: Vec) -> Arc { + let span = bibliography.span(); + let entries = load(&bibliography.path(), &bibliography.data()).unwrap(); + let style = bibliography.style(StyleChain::default()); + let bib_location = bibliography.0.location().unwrap(); + let ref_location = |target: &Entry| { + let i = entries + .iter() + .position(|entry| entry.key() == target.key()) + .unwrap_or_default(); + bib_location.variant(i) + }; + + let mut db = Database::new(); + let mut ids = HashMap::new(); + let mut preliminary = vec![]; + + for citation in citations { + let cite_id = citation.0.location().unwrap(); + let entries = citation + .keys() + .into_iter() + .map(|key| { + let entry = entries.iter().find(|entry| entry.key() == key)?; + ids.entry(entry.key()).or_insert(cite_id); + db.push(entry); + Some(entry) + }) + .collect::>>(); + preliminary.push((citation, entries)); + } + + let mut current = CitationStyle::Numerical; + let mut citation_style: Box = + Box::new(style::Numerical::new()); + + let citations = preliminary + .into_iter() + .map(|(citation, cited)| { + let location = citation.0.location().unwrap(); + let Some(cited) = cited else { return (location, None) }; + + let mut supplement = citation.supplement(StyleChain::default()); + let brackets = citation.brackets(StyleChain::default()); + let style = citation + .style(StyleChain::default()) + .unwrap_or(style.default_citation_style()); + + if style != current { + current = style; + citation_style = match style { + CitationStyle::Numerical => Box::new(style::Numerical::new()), + CitationStyle::Alphanumerical => { + Box::new(style::Alphanumerical::new()) + } + CitationStyle::ChicagoAuthorDate => { + Box::new(style::ChicagoAuthorDate::new()) + } + CitationStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), + CitationStyle::ChicagoAuthorTitle => { + Box::new(style::AuthorTitle::new()) + } + CitationStyle::Keys => Box::new(style::Keys::new()), + }; + } + + let len = cited.len(); + let mut content = Content::empty(); + for (i, entry) in cited.into_iter().enumerate() { + let supplement = if i + 1 == len { supplement.take() } else { None }; + let mut display = db + .citation( + &mut *citation_style, + &[Citation { + entry, + supplement: supplement.is_some().then_some(SUPPLEMENT), + }], + ) + .display; + + if style.is_short() { + display.value = display.value.replace(' ', "\u{a0}"); + } + + if brackets && len == 1 { + display = display.with_default_brackets(&*citation_style); + } + + if i > 0 { + content += TextElem::packed(",\u{a0}"); + } + + // Format and link to the reference entry. + content += format_display_string(&display, supplement, citation.span()) + .linked(Destination::Location(ref_location(entry))); + } + + if brackets && len > 1 { + content = match citation_style.brackets() { + Brackets::None => content, + Brackets::Round => { + TextElem::packed('(') + content + TextElem::packed(')') + } + Brackets::Square => { + TextElem::packed('[') + content + TextElem::packed(']') + } + }; + } + + if style == CitationStyle::ChicagoNotes { + content = FootnoteElem::with_content(content).pack(); + } + + (location, Some(content)) + }) + .collect(); + + let bibliography_style: Box = match style { + BibliographyStyle::Apa => Box::new(style::Apa::new()), + BibliographyStyle::ChicagoAuthorDate => Box::new(style::ChicagoAuthorDate::new()), + BibliographyStyle::ChicagoNotes => Box::new(style::ChicagoNotes::new()), + BibliographyStyle::Ieee => Box::new(style::Ieee::new()), + BibliographyStyle::Mla => Box::new(style::Mla::new()), + }; + + let references = db + .bibliography(&*bibliography_style, None) + .into_iter() + .map(|reference| { + let backlink = ref_location(reference.entry); + let prefix = reference.prefix.map(|prefix| { + // Format and link to first citation. + let bracketed = prefix.with_default_brackets(&*citation_style); + format_display_string(&bracketed, None, span) + .linked(Destination::Location(ids[reference.entry.key()])) + .backlinked(backlink) + }); + + let mut reference = format_display_string(&reference.display, None, span); + if prefix.is_none() { + reference = reference.backlinked(backlink); + } + + (prefix, reference) + }) + .collect(); + + Arc::new(Works { citations, references }) +} + +/// Load bibliography entries from a path. +#[comemo::memoize] +fn load(paths: &BibPaths, data: &[Bytes]) -> StrResult> { + let mut result = EcoVec::new(); + + // We might have multiple bib/yaml files + for (path, bytes) in paths.0.iter().zip(data) { + let src = std::str::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?; + let entries = parse_bib(path, src)?; + result.extend(entries); + } + + // Biblatex only checks for duplicate keys within files + // -> We have to do this between files again + let mut keys = result.iter().map(|r| r.key()).collect::>(); + keys.sort_unstable(); + // Waiting for `slice_partition_dedup` #54279 + let mut duplicates = Vec::new(); + for pair in keys.windows(2) { + if pair[0] == pair[1] { + duplicates.push(pair[0]); + } + } + + if !duplicates.is_empty() { + Err(eco_format!("duplicate bibliography keys: {}", duplicates.join(", "))) + } else { + Ok(result) + } +} + +/// Parse a bibliography file (bib/yml/yaml) +fn parse_bib(path_str: &str, src: &str) -> StrResult> { + let path = Path::new(path_str); + let ext = path.extension().and_then(OsStr::to_str).unwrap_or_default(); + match ext.to_lowercase().as_str() { + "yml" | "yaml" => { + hayagriva::io::from_yaml_str(src).map_err(format_hayagriva_error) + } + "bib" => hayagriva::io::from_biblatex_str(src).map_err(|err| { + err.into_iter() + .next() + .map(|error| format_biblatex_error(path_str, src, error)) + .unwrap_or_else(|| eco_format!("failed to parse {path_str}")) + }), + _ => bail!("unknown bibliography format (must be .yml/.yaml or .bib)"), + } +} + +/// Format a Hayagriva loading error. +fn format_hayagriva_error(error: YamlBibliographyError) -> EcoString { + eco_format!("{error}") +} + +/// Format a BibLaTeX loading error. +fn format_biblatex_error(path: &str, src: &str, error: BibLaTeXError) -> EcoString { + let (span, msg) = match error { + BibLaTeXError::Parse(error) => (error.span, error.kind.to_string()), + BibLaTeXError::Type(error) => (error.span, error.kind.to_string()), + }; + let line = src.get(..span.start).unwrap_or_default().lines().count(); + eco_format!("parsing failed at {path}:{line}: {msg}") +} + +/// Hayagriva only supports strings, but we have a content supplement. To deal +/// with this, we pass this string to hayagriva instead of our content, find it +/// in the output and replace it with the content. +const SUPPLEMENT: &str = "cdc579c45cf3d648905c142c7082683f"; + +/// Format a display string into content. +fn format_display_string( + string: &DisplayString, + mut supplement: Option, + span: Span, +) -> Content { + let mut stops: Vec<_> = string + .formatting + .iter() + .flat_map(|(range, _)| [range.start, range.end]) + .collect(); + + if let Some(i) = string.value.find(SUPPLEMENT) { + stops.push(i); + stops.push(i + SUPPLEMENT.len()); + } + + stops.sort(); + stops.dedup(); + stops.push(string.value.len()); + + let mut start = 0; + let mut seq = vec![]; + for stop in stops { + let segment = string.value.get(start..stop).unwrap_or_default(); + if segment.is_empty() { + continue; + } + + let mut content = if segment == SUPPLEMENT && supplement.is_some() { + supplement.take().unwrap_or_default() + } else { + TextElem::packed(segment).spanned(span) + }; + + for (range, fmt) in &string.formatting { + if !range.contains(&start) { + continue; + } + + content = match fmt { + Formatting::Bold => content.strong(), + Formatting::Italic => content.emph(), + Formatting::Link(link) => { + LinkElem::new(Destination::Url(link.as_str().into()).into(), content) + .pack() + } + }; + } + + seq.push(content); + start = stop; + } + + Content::sequence(seq) +} diff --git a/crates/typst-library/src/meta/context.rs b/crates/typst-library/src/meta/context.rs new file mode 100644 index 00000000..a42c6980 --- /dev/null +++ b/crates/typst-library/src/meta/context.rs @@ -0,0 +1,220 @@ +use crate::prelude::*; + +/// Provides access to the location of content. +/// +/// This is useful in combination with [queries]($func/query), +/// [counters]($func/counter), [state]($func/state), and [links]($func/link). +/// See their documentation for more details. +/// +/// ```example +/// #locate(loc => [ +/// My location: \ +/// #loc.position()! +/// ]) +/// ``` +/// +/// ## Methods +/// ### page() +/// Returns the page number for this location. +/// +/// Note that this does not return the value of the [page counter]($func/counter) +/// at this location, but the true page number (starting from one). +/// +/// If you want to know the value of the page counter, use +/// `{counter(page).at(loc)}` instead. +/// +/// - returns: integer +/// +/// ### position() +/// Returns a dictionary with the page number and the x, y position for this +/// location. The page number starts at one and the coordinates are measured +/// from the top-left of the page. +/// +/// If you only need the page number, use `page()` instead as it allows Typst +/// to skip unnecessary work. +/// +/// - returns: dictionary +/// +/// ### page-numbering() +/// Returns the page numbering pattern of the page at this location. This can be +/// used when displaying the page counter in order to obtain the local numbering. +/// This is useful if you are building custom indices or outlines. +/// +/// If the page numbering is set to `none` at that location, this function returns `none`. +/// +/// - returns: string or function or none +/// +/// Display: Locate +/// Category: meta +#[func] +pub fn locate( + /// A function that receives a `location`. Its return value is displayed + /// in the document. + /// + /// This function is called once for each time the content returned by + /// `locate` appears in the document. That makes it possible to generate + /// content that depends on its own location in the document. + func: Func, +) -> Content { + LocateElem::new(func).pack() +} + +/// Executes a `locate` call. +/// +/// Display: Locate +/// Category: special +#[element(Locatable, Show)] +struct LocateElem { + /// The function to call with the location. + #[required] + func: Func, +} + +impl Show for LocateElem { + #[tracing::instrument(name = "LocateElem::show", skip(self, vt))] + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult { + Ok(vt.delayed(|vt| { + let location = self.0.location().unwrap(); + Ok(self.func().call_vt(vt, [location])?.display()) + })) + } +} + +/// Provides access to active styles. +/// +/// The styles are currently opaque and only useful in combination with the +/// [`measure`]($func/measure) function. See its documentation for more details. +/// In the future, the provided styles might also be directly accessed to look +/// up styles defined by [set rules]($styling/#set-rules). +/// +/// ```example +/// #let thing(body) = style(styles => { +/// let size = measure(body, styles) +/// [Width of "#body" is #size.width] +/// }) +/// +/// #thing[Hey] \ +/// #thing[Welcome] +/// ``` +/// +/// Display: Style +/// Category: meta +#[func] +pub fn style( + /// A function to call with the styles. Its return value is displayed + /// in the document. + /// + /// This function is called once for each time the content returned by + /// `style` appears in the document. That makes it possible to generate + /// content that depends on the style context it appears in. + func: Func, +) -> Content { + StyleElem::new(func).pack() +} + +/// Executes a style access. +/// +/// Display: Style +/// Category: special +#[element(Show)] +struct StyleElem { + /// The function to call with the styles. + #[required] + func: Func, +} + +impl Show for StyleElem { + #[tracing::instrument(name = "StyleElem::show", skip_all)] + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + Ok(self.func().call_vt(vt, [styles.to_map()])?.display()) + } +} + +/// Provides access to the current outer container's (or page's, if none) size +/// (width and height). +/// +/// The given function must accept a single parameter, `size`, which is a +/// dictionary with keys `width` and `height`, both of type +/// [`length`]($type/length). +/// + +/// ```example +/// #let text = lorem(30) +/// #layout(size => style(styles => [ +/// #let (height,) = measure( +/// block(width: size.width, text), +/// styles, +/// ) +/// This text is #height high with +/// the current page width: \ +/// #text +/// ])) +/// ``` +/// +/// If the `layout` call is placed inside of a box width a width of `{800pt}` +/// and a height of `{400pt}`, then the specified function will be given the +/// parameter `{(width: 800pt, height: 400pt)}`. If it placed directly into the +/// page it receives the page's dimensions minus its margins. This is mostly +/// useful in combination with [measurement]($func/measure). +/// +/// You can also use this function to resolve [`ratio`]($type/ratio) to fixed +/// lengths. This might come in handy if you're building your own layout +/// abstractions. +/// +/// ```example +/// #layout(size => { +/// let half = 50% * size.width +/// [Half a page is #half wide.] +/// }) +/// ``` +/// +/// Note that this function will provide an infinite width or height if one of +/// the page width or height is `auto`, respectively. +/// +/// Display: Layout +/// Category: meta +#[func] +pub fn layout( + /// A function to call with the outer container's size. Its return value is + /// displayed in the document. + /// + /// The container's size is given as a [dictionary]($type/dictionary) with + /// the keys `width` and `height`. + /// + /// This function is called once for each time the content returned by + /// `layout` appears in the document. That makes it possible to generate + /// content that depends on the size of the container it is inside of. + func: Func, +) -> Content { + LayoutElem::new(func).pack() +} + +/// Executes a `layout` call. +/// +/// Display: Layout +/// Category: special +#[element(Layout)] +struct LayoutElem { + /// The function to call with the outer container's (or page's) size. + #[required] + func: Func, +} + +impl Layout for LayoutElem { + #[tracing::instrument(name = "LayoutElem::layout", skip_all)] + fn layout( + &self, + vt: &mut Vt, + styles: StyleChain, + regions: Regions, + ) -> SourceResult { + // Gets the current region's base size, which will be the size of the + // outer container, or of the page if there is no such container. + let Size { x, y } = regions.base(); + let result = self + .func() + .call_vt(vt, [dict! { "width" => x, "height" => y }])? + .display(); + result.layout(vt, styles, regions) + } +} diff --git a/crates/typst-library/src/meta/counter.rs b/crates/typst-library/src/meta/counter.rs new file mode 100644 index 00000000..9a223b32 --- /dev/null +++ b/crates/typst-library/src/meta/counter.rs @@ -0,0 +1,683 @@ +use std::fmt::{self, Debug, Formatter, Write}; +use std::str::FromStr; + +use ecow::{eco_vec, EcoVec}; +use smallvec::{smallvec, SmallVec}; +use typst::eval::Tracer; +use typst::model::DelayedErrors; + +use super::{FigureElem, HeadingElem, Numbering, NumberingPattern}; +use crate::layout::PageElem; +use crate::math::EquationElem; +use crate::prelude::*; + +/// Counts through pages, elements, and more. +/// +/// With the counter function, you can access and modify counters for pages, +/// headings, figures, and more. Moreover, you can define custom counters for +/// other things you want to count. +/// +/// ## Displaying a counter { #displaying } +/// To display the current value of the heading counter, you call the `counter` +/// function with the `key` set to `heading` and then call the `display` method +/// on the counter. To see any output, you also have to enable heading +/// [numbering]($func/heading.numbering). +/// +/// The `display` method optionally takes an argument telling it how to format +/// the counter. This can be a [numbering pattern or a +/// function]($func/numbering). +/// +/// ```example +/// #set heading(numbering: "1.") +/// +/// = Introduction +/// Some text here. +/// +/// = Background +/// The current value is: +/// #counter(heading).display() +/// +/// Or in roman numerals: +/// #counter(heading).display("I") +/// ``` +/// +/// ## Modifying a counter { #modifying } +/// To modify a counter, you can use the `step` and `update` methods: +/// +/// - The `step` method increases the value of the counter by one. Because +/// counters can have multiple levels (in the case of headings for sections, +/// subsections, and so on), the `step` method optionally takes a `level` +/// argument. If given, the counter steps at the given depth. +/// +/// - The `update` method allows you to arbitrarily modify the counter. In its +/// basic form, you give it an integer (or multiple for multiple levels). For +/// more flexibility, you can instead also give it a function that gets the +/// current value and returns a new value. +/// +/// The heading counter is stepped before the heading is displayed, so +/// `Analysis` gets the number seven even though the counter is at six after the +/// second update. +/// +/// ```example +/// #set heading(numbering: "1.") +/// +/// = Introduction +/// #counter(heading).step() +/// +/// = Background +/// #counter(heading).update(3) +/// #counter(heading).update(n => n * 2) +/// +/// = Analysis +/// Let's skip 7.1. +/// #counter(heading).step(level: 2) +/// +/// == Analysis +/// Still at #counter(heading).display(). +/// ``` +/// +/// ## Custom counters { #custom-counters } +/// To define your own counter, call the `counter` function with a string as a +/// key. This key identifies the counter globally. +/// +/// ```example +/// #let mine = counter("mycounter") +/// #mine.display() \ +/// #mine.step() +/// #mine.display() \ +/// #mine.update(c => c * 3) +/// #mine.display() \ +/// ``` +/// +/// ## How to step { #how-to-step } +/// When you define and use a custom counter, in general, you should first step +/// the counter and then display it. This way, the stepping behaviour of a +/// counter can depend on the element it is stepped for. If you were writing a +/// counter for, let's say, theorems, your theorem's definition would thus first +/// include the counter step and only then display the counter and the theorem's +/// contents. +/// +/// ```example +/// #let c = counter("theorem") +/// #let theorem(it) = block[ +/// #c.step() +/// *Theorem #c.display():* #it +/// ] +/// +/// #theorem[$1 = 1$] +/// #theorem[$2 < 3$] +/// ``` +/// +/// The rationale behind this is best explained on the example of the heading +/// counter: An update to the heading counter depends on the heading's level. +/// By stepping directly before the heading, we can correctly step from `1` to +/// `1.1` when encountering a level 2 heading. If we were to step after the +/// heading, we wouldn't know what to step to. +/// +/// Because counters should always be stepped before the elements they count, +/// they always start at zero. This way, they are at one for the first display +/// (which happens after the first step). +/// +/// ## Page counter { #page-counter } +/// The page counter is special. It is automatically stepped at each pagebreak. +/// But like other counters, you can also step it manually. For example, you +/// could have Roman page numbers for your preface, then switch to Arabic page +/// numbers for your main content and reset the page counter to one. +/// +/// ```example +/// >>> #set page( +/// >>> height: 100pt, +/// >>> margin: (bottom: 24pt, rest: 16pt), +/// >>> ) +/// #set page(numbering: "(i)") +/// +/// = Preface +/// The preface is numbered with +/// roman numerals. +/// +/// #set page(numbering: "1 / 1") +/// #counter(page).update(1) +/// +/// = Main text +/// Here, the counter is reset to one. +/// We also display both the current +/// page and total number of pages in +/// Arabic numbers. +/// ``` +/// +/// ## Time travel { #time-travel } +/// Counters can travel through time! You can find out the final value of the +/// counter before it is reached and even determine what the value was at any +/// particular location in the document. +/// +/// ```example +/// #let mine = counter("mycounter") +/// +/// = Values +/// #locate(loc => { +/// let start-val = mine.at(loc) +/// let elements = query(, loc) +/// let intro-val = mine.at( +/// elements.first().location() +/// ) +/// let final-val = mine.final(loc) +/// [Starts as: #start-val \ +/// Value at intro is: #intro-val \ +/// Final value is: #final-val \ ] +/// }) +/// +/// #mine.update(n => n + 3) +/// +/// = Introduction +/// #lorem(10) +/// +/// #mine.step() +/// #mine.step() +/// ``` +/// +/// Let's dissect what happens in the example above: +/// +/// - We call [`locate`]($func/locate) to get access to the current location in +/// the document. We then pass this location to our counter's `at` method to +/// get its value at the current location. The `at` method always returns an +/// array because counters can have multiple levels. As the counter starts at +/// one, the first value is thus `{(1,)}`. +/// +/// - We now [`query`]($func/query) the document for all elements with the +/// `{}` label. The result is an array from which we extract the first +/// (and only) element's [location]($type/content.location). We then look up +/// the value of the counter at that location. The first update to the counter +/// sets it to `{1 + 3 = 4}`. At the introduction heading, the value is thus +/// `{(4,)}`. +/// +/// - Last but not least, we call the `final` method on the counter. It tells us +/// what the counter's value will be at the end of the document. We also need +/// to give it a location to prove that we are inside of a `locate` call, but +/// which one doesn't matter. After the heading follow two calls to `step()`, +/// so the final value is `{(6,)}`. +/// +/// ## Other kinds of state { #other-state } +/// The `counter` function is closely related to [state]($func/state) function. +/// Read its documentation for more details on state management in Typst and +/// why it doesn't just use normal variables for counters. +/// +/// ## Methods +/// ### display() +/// Displays the value of the counter. +/// +/// - numbering: string or function (positional) +/// A [numbering pattern or a function]($func/numbering), which specifies how +/// to display the counter. If given a function, that function receives each +/// number of the counter as a separate argument. If the amount of numbers +/// varies, e.g. for the heading argument, you can use an +/// [argument sink]($type/arguments). +/// +/// If this is omitted, displays the counter with the numbering style for the +/// counted element or with the pattern `{"1.1"}` if no such style exists. +/// +/// - both: boolean (named) +/// If enabled, displays the current and final top-level count together. Both +/// can be styled through a single numbering pattern. This is used by the page +/// numbering property to display the current and total number of pages when a +/// pattern like `{"1 / 1"}` is given. +/// +/// - returns: content +/// +/// ### step() +/// Increases the value of the counter by one. +/// +/// The update will be in effect at the position where the returned content is +/// inserted into the document. If you don't put the output into the document, +/// nothing happens! This would be the case, for example, if you write +/// `{let _ = counter(page).step()}`. Counter updates are always applied in +/// layout order and in that case, Typst wouldn't know when to step the counter. +/// +/// - level: integer (named) +/// The depth at which to step the counter. Defaults to `{1}`. +/// +/// - returns: content +/// +/// ### update() +/// Updates the value of the counter. +/// +/// Just like with `step`, the update only occurs if you put the resulting +/// content into the document. +/// +/// - value: integer or array or function (positional, required) +/// If given an integer or array of integers, sets the counter to that value. +/// If given a function, that function receives the previous counter value +/// (with each number as a separate argument) and has to return the new +/// value (integer or array). +/// +/// - returns: content +/// +/// ### at() +/// Gets the value of the counter at the given location. Always returns an +/// array of integers, even if the counter has just one number. +/// +/// - location: location (positional, required) +/// The location at which the counter value should be retrieved. A suitable +/// location can be retrieved from [`locate`]($func/locate) or +/// [`query`]($func/query). +/// +/// - returns: array +/// +/// ### final() +/// Gets the value of the counter at the end of the document. Always returns an +/// array of integers, even if the counter has just one number. +/// +/// - location: location (positional, required) +/// Can be any location. Why is it required then? Typst has to evaluate parts +/// of your code multiple times to determine all counter values. By only +/// allowing this method within [`locate`]($func/locate) calls, the amount of +/// code that can depend on the method's result is reduced. If you could call +/// `final` directly at the top level of a module, the evaluation of the whole +/// module and its exports could depend on the counter's value. +/// +/// - returns: array +/// +/// Display: Counter +/// Category: meta +#[func] +pub fn counter( + /// The key that identifies this counter. + /// + /// - If it is a string, creates a custom counter that is only affected by + /// manual updates, + /// - If this is a `{