@react-x11/components 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/charts/node.d.ts +11 -1
- package/dist/charts/node.d.ts.map +1 -1
- package/dist/charts/node.js +44 -14
- package/dist/charts/node.js.map +1 -1
- package/dist/charts/render.d.ts +12 -3
- package/dist/charts/render.d.ts.map +1 -1
- package/dist/charts/render.js +94 -25
- package/dist/charts/render.js.map +1 -1
- package/dist/code/index.d.ts.map +1 -1
- package/dist/code/index.js +27 -24
- package/dist/code/index.js.map +1 -1
- package/dist/code-editor/completion.d.ts.map +1 -1
- package/dist/code-editor/completion.js +3 -2
- package/dist/code-editor/completion.js.map +1 -1
- package/dist/code-editor/index.d.ts.map +1 -1
- package/dist/code-editor/index.js +19 -0
- package/dist/code-editor/index.js.map +1 -1
- package/dist/code-editor/node.d.ts +177 -7
- package/dist/code-editor/node.d.ts.map +1 -1
- package/dist/code-editor/node.js +1329 -164
- package/dist/code-editor/node.js.map +1 -1
- package/dist/code-language/index.d.ts +1 -1
- package/dist/code-language/index.d.ts.map +1 -1
- package/dist/code-language/index.js +1 -1
- package/dist/code-language/index.js.map +1 -1
- package/dist/code-language/languages/glsl.d.ts.map +1 -1
- package/dist/code-language/languages/glsl.js +10 -2
- package/dist/code-language/languages/glsl.js.map +1 -1
- package/dist/code-language/languages/javascript.d.ts.map +1 -1
- package/dist/code-language/languages/javascript.js +18 -3
- package/dist/code-language/languages/javascript.js.map +1 -1
- package/dist/code-language/languages/json.d.ts.map +1 -1
- package/dist/code-language/languages/json.js +10 -2
- package/dist/code-language/languages/json.js.map +1 -1
- package/dist/code-language/languages/shell.d.ts.map +1 -1
- package/dist/code-language/languages/shell.js +10 -2
- package/dist/code-language/languages/shell.js.map +1 -1
- package/dist/code-language/languages/sql.d.ts.map +1 -1
- package/dist/code-language/languages/sql.js +20 -2
- package/dist/code-language/languages/sql.js.map +1 -1
- package/dist/code-language/runs.d.ts +31 -0
- package/dist/code-language/runs.d.ts.map +1 -1
- package/dist/code-language/runs.js +105 -0
- package/dist/code-language/runs.js.map +1 -1
- package/dist/code-language/stream.d.ts +22 -0
- package/dist/code-language/stream.d.ts.map +1 -1
- package/dist/code-language/stream.js +259 -9
- package/dist/code-language/stream.js.map +1 -1
- package/dist/code-language/types.d.ts +5 -4
- package/dist/code-language/types.d.ts.map +1 -1
- package/dist/codeblock/index.d.ts +26 -2
- package/dist/codeblock/index.d.ts.map +1 -1
- package/dist/codeblock/index.js +57 -4
- package/dist/codeblock/index.js.map +1 -1
- package/dist/flow/draw.d.ts +50 -0
- package/dist/flow/draw.d.ts.map +1 -1
- package/dist/flow/draw.js +132 -33
- package/dist/flow/draw.js.map +1 -1
- package/dist/flow/gl/color.d.ts +13 -0
- package/dist/flow/gl/color.d.ts.map +1 -0
- package/dist/flow/gl/color.js +82 -0
- package/dist/flow/gl/color.js.map +1 -0
- package/dist/flow/gl/index.d.ts +60 -0
- package/dist/flow/gl/index.d.ts.map +1 -0
- package/dist/flow/gl/index.js +283 -0
- package/dist/flow/gl/index.js.map +1 -0
- package/dist/flow/gl/pack.d.ts +133 -0
- package/dist/flow/gl/pack.d.ts.map +1 -0
- package/dist/flow/gl/pack.js +408 -0
- package/dist/flow/gl/pack.js.map +1 -0
- package/dist/flow/gl/renderer.d.ts +95 -0
- package/dist/flow/gl/renderer.d.ts.map +1 -0
- package/dist/flow/gl/renderer.js +459 -0
- package/dist/flow/gl/renderer.js.map +1 -0
- package/dist/flow/gl/shaders.d.ts +79 -0
- package/dist/flow/gl/shaders.d.ts.map +1 -0
- package/dist/flow/gl/shaders.js +346 -0
- package/dist/flow/gl/shaders.js.map +1 -0
- package/dist/flow/gl/text.d.ts +318 -0
- package/dist/flow/gl/text.d.ts.map +1 -0
- package/dist/flow/gl/text.js +1103 -0
- package/dist/flow/gl/text.js.map +1 -0
- package/dist/flow/index.d.ts +1 -1
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +619 -64
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/node.d.ts +621 -72
- package/dist/flow/node.d.ts.map +1 -1
- package/dist/flow/node.js +2253 -1018
- package/dist/flow/node.js.map +1 -1
- package/dist/flow/paint.d.ts +35 -0
- package/dist/flow/paint.d.ts.map +1 -0
- package/dist/flow/paint.js +393 -0
- package/dist/flow/paint.js.map +1 -0
- package/dist/flow/paths.d.ts +14 -0
- package/dist/flow/paths.d.ts.map +1 -1
- package/dist/flow/paths.js +45 -19
- package/dist/flow/paths.js.map +1 -1
- package/dist/flow/scene.d.ts +584 -0
- package/dist/flow/scene.d.ts.map +1 -0
- package/dist/flow/scene.js +1453 -0
- package/dist/flow/scene.js.map +1 -0
- package/dist/flow/types.d.ts +111 -1
- package/dist/flow/types.d.ts.map +1 -1
- package/dist/html/controls.d.ts +28 -0
- package/dist/html/controls.d.ts.map +1 -1
- package/dist/html/controls.js +52 -2
- package/dist/html/controls.js.map +1 -1
- package/dist/html/css/calc.d.ts +14 -0
- package/dist/html/css/calc.d.ts.map +1 -0
- package/dist/html/css/calc.js +344 -0
- package/dist/html/css/calc.js.map +1 -0
- package/dist/html/css/cascade.d.ts +262 -6
- package/dist/html/css/cascade.d.ts.map +1 -1
- package/dist/html/css/cascade.js +1373 -67
- package/dist/html/css/cascade.js.map +1 -1
- package/dist/html/css/color.d.ts +23 -0
- package/dist/html/css/color.d.ts.map +1 -0
- package/dist/html/css/color.js +740 -0
- package/dist/html/css/color.js.map +1 -0
- package/dist/html/css/content.d.ts +92 -0
- package/dist/html/css/content.d.ts.map +1 -0
- package/dist/html/css/content.js +388 -0
- package/dist/html/css/content.js.map +1 -0
- package/dist/html/css/counter-styles.d.ts +76 -0
- package/dist/html/css/counter-styles.d.ts.map +1 -0
- package/dist/html/css/counter-styles.js +893 -0
- package/dist/html/css/counter-styles.js.map +1 -0
- package/dist/html/css/decode.d.ts +12 -0
- package/dist/html/css/decode.d.ts.map +1 -0
- package/dist/html/css/decode.js +116 -0
- package/dist/html/css/decode.js.map +1 -0
- package/dist/html/css/parse.d.ts +110 -10
- package/dist/html/css/parse.d.ts.map +1 -1
- package/dist/html/css/parse.js +1473 -180
- package/dist/html/css/parse.js.map +1 -1
- package/dist/html/css/style.d.ts +501 -15
- package/dist/html/css/style.d.ts.map +1 -1
- package/dist/html/css/style.js +3729 -206
- package/dist/html/css/style.js.map +1 -1
- package/dist/html/css/ua.d.ts.map +1 -1
- package/dist/html/css/ua.js +65 -16
- package/dist/html/css/ua.js.map +1 -1
- package/dist/html/css/values.d.ts +37 -3
- package/dist/html/css/values.d.ts.map +1 -1
- package/dist/html/css/values.js +122 -48
- package/dist/html/css/values.js.map +1 -1
- package/dist/html/css/vars.d.ts +52 -0
- package/dist/html/css/vars.d.ts.map +1 -0
- package/dist/html/css/vars.js +358 -0
- package/dist/html/css/vars.js.map +1 -0
- package/dist/html/dom.d.ts +30 -4
- package/dist/html/dom.d.ts.map +1 -1
- package/dist/html/dom.js +278 -17
- package/dist/html/dom.js.map +1 -1
- package/dist/html/fonts.d.ts +106 -0
- package/dist/html/fonts.d.ts.map +1 -0
- package/dist/html/fonts.js +565 -0
- package/dist/html/fonts.js.map +1 -0
- package/dist/html/gif.d.ts +15 -0
- package/dist/html/gif.d.ts.map +1 -0
- package/dist/html/gif.js +229 -0
- package/dist/html/gif.js.map +1 -0
- package/dist/html/index.d.ts +50 -11
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +115 -9
- package/dist/html/index.js.map +1 -1
- package/dist/html/layout/block.d.ts +226 -3
- package/dist/html/layout/block.d.ts.map +1 -1
- package/dist/html/layout/block.js +3077 -219
- package/dist/html/layout/block.js.map +1 -1
- package/dist/html/layout/boxes.d.ts +367 -17
- package/dist/html/layout/boxes.d.ts.map +1 -1
- package/dist/html/layout/boxes.js +1742 -182
- package/dist/html/layout/boxes.js.map +1 -1
- package/dist/html/layout/cache.d.ts +58 -0
- package/dist/html/layout/cache.d.ts.map +1 -0
- package/dist/html/layout/cache.js +224 -0
- package/dist/html/layout/cache.js.map +1 -0
- package/dist/html/layout/collapse.d.ts +45 -0
- package/dist/html/layout/collapse.d.ts.map +1 -0
- package/dist/html/layout/collapse.js +246 -0
- package/dist/html/layout/collapse.js.map +1 -0
- package/dist/html/layout/css-grid.d.ts +7 -0
- package/dist/html/layout/css-grid.d.ts.map +1 -0
- package/dist/html/layout/css-grid.js +900 -0
- package/dist/html/layout/css-grid.js.map +1 -0
- package/dist/html/layout/flex.d.ts.map +1 -1
- package/dist/html/layout/flex.js +1025 -89
- package/dist/html/layout/flex.js.map +1 -1
- package/dist/html/layout/floats.d.ts +45 -6
- package/dist/html/layout/floats.d.ts.map +1 -1
- package/dist/html/layout/floats.js +113 -14
- package/dist/html/layout/floats.js.map +1 -1
- package/dist/html/layout/grid-lines.d.ts +39 -0
- package/dist/html/layout/grid-lines.d.ts.map +1 -0
- package/dist/html/layout/grid-lines.js +119 -0
- package/dist/html/layout/grid-lines.js.map +1 -0
- package/dist/html/layout/grid.d.ts +53 -0
- package/dist/html/layout/grid.d.ts.map +1 -0
- package/dist/html/layout/grid.js +153 -0
- package/dist/html/layout/grid.js.map +1 -0
- package/dist/html/layout/inline.d.ts +154 -1
- package/dist/html/layout/inline.d.ts.map +1 -1
- package/dist/html/layout/inline.js +3598 -175
- package/dist/html/layout/inline.js.map +1 -1
- package/dist/html/layout/shaping.d.ts +6 -0
- package/dist/html/layout/shaping.d.ts.map +1 -0
- package/dist/html/layout/shaping.js +100 -0
- package/dist/html/layout/shaping.js.map +1 -0
- package/dist/html/layout/table.d.ts +3 -0
- package/dist/html/layout/table.d.ts.map +1 -1
- package/dist/html/layout/table.js +681 -175
- package/dist/html/layout/table.js.map +1 -1
- package/dist/html/layout/tracks.d.ts +44 -0
- package/dist/html/layout/tracks.d.ts.map +1 -0
- package/dist/html/layout/tracks.js +349 -0
- package/dist/html/layout/tracks.js.map +1 -0
- package/dist/html/node.d.ts +169 -4
- package/dist/html/node.d.ts.map +1 -1
- package/dist/html/node.js +1429 -137
- package/dist/html/node.js.map +1 -1
- package/dist/html/paint.d.ts +92 -3
- package/dist/html/paint.d.ts.map +1 -1
- package/dist/html/paint.js +3635 -107
- package/dist/html/paint.js.map +1 -1
- package/dist/html/resources.d.ts +60 -14
- package/dist/html/resources.d.ts.map +1 -1
- package/dist/html/resources.js +122 -36
- package/dist/html/resources.js.map +1 -1
- package/dist/html/surfaces.d.ts +23 -0
- package/dist/html/surfaces.d.ts.map +1 -0
- package/dist/html/surfaces.js +68 -0
- package/dist/html/surfaces.js.map +1 -0
- package/dist/html/svg.d.ts +80 -0
- package/dist/html/svg.d.ts.map +1 -0
- package/dist/html/svg.js +442 -0
- package/dist/html/svg.js.map +1 -0
- package/dist/html/url.d.ts +23 -0
- package/dist/html/url.d.ts.map +1 -0
- package/dist/html/url.js +80 -0
- package/dist/html/url.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codelines.d.ts +42 -0
- package/dist/internal/codelines.d.ts.map +1 -0
- package/dist/internal/codelines.js +170 -0
- package/dist/internal/codelines.js.map +1 -0
- package/dist/internal/collate.d.ts +3 -0
- package/dist/internal/collate.d.ts.map +1 -0
- package/dist/internal/collate.js +11 -0
- package/dist/internal/collate.js.map +1 -0
- package/dist/internal/markdown/ast.d.ts +11 -0
- package/dist/internal/markdown/ast.d.ts.map +1 -1
- package/dist/internal/markdown/parse.d.ts.map +1 -1
- package/dist/internal/markdown/parse.js +109 -8
- package/dist/internal/markdown/parse.js.map +1 -1
- package/dist/internal/prewarm.d.ts +18 -0
- package/dist/internal/prewarm.d.ts.map +1 -0
- package/dist/internal/prewarm.js +42 -0
- package/dist/internal/prewarm.js.map +1 -0
- package/dist/internal/sdf.d.ts +20 -0
- package/dist/internal/sdf.d.ts.map +1 -0
- package/dist/internal/sdf.js +216 -0
- package/dist/internal/sdf.js.map +1 -0
- package/dist/internal/splice.d.ts +12 -0
- package/dist/internal/splice.d.ts.map +1 -0
- package/dist/internal/splice.js +23 -0
- package/dist/internal/splice.js.map +1 -0
- package/dist/internal/window.d.ts +12 -0
- package/dist/internal/window.d.ts.map +1 -1
- package/dist/internal/window.js +23 -6
- package/dist/internal/window.js.map +1 -1
- package/dist/maps/anchors.d.ts +6 -1
- package/dist/maps/anchors.d.ts.map +1 -1
- package/dist/maps/anchors.js +260 -1
- package/dist/maps/anchors.js.map +1 -1
- package/dist/maps/controller.d.ts +57 -0
- package/dist/maps/controller.d.ts.map +1 -1
- package/dist/maps/controller.js +97 -18
- package/dist/maps/controller.js.map +1 -1
- package/dist/maps/gl/buckets.d.ts +5 -1
- package/dist/maps/gl/buckets.d.ts.map +1 -1
- package/dist/maps/gl/buckets.js +5 -2
- package/dist/maps/gl/buckets.js.map +1 -1
- package/dist/maps/gl/build-worker.d.ts +2 -0
- package/dist/maps/gl/build-worker.d.ts.map +1 -1
- package/dist/maps/gl/build-worker.js +1 -1
- package/dist/maps/gl/build-worker.js.map +1 -1
- package/dist/maps/gl/cover.d.ts +23 -4
- package/dist/maps/gl/cover.d.ts.map +1 -1
- package/dist/maps/gl/cover.js +61 -48
- package/dist/maps/gl/cover.js.map +1 -1
- package/dist/maps/gl/placement.d.ts +21 -8
- package/dist/maps/gl/placement.d.ts.map +1 -1
- package/dist/maps/gl/placement.js +133 -58
- package/dist/maps/gl/placement.js.map +1 -1
- package/dist/maps/gl/renderer.d.ts +36 -4
- package/dist/maps/gl/renderer.d.ts.map +1 -1
- package/dist/maps/gl/renderer.js +81 -24
- package/dist/maps/gl/renderer.js.map +1 -1
- package/dist/maps/gl/shaders.d.ts +18 -17
- package/dist/maps/gl/shaders.d.ts.map +1 -1
- package/dist/maps/gl/shaders.js +34 -32
- package/dist/maps/gl/shaders.js.map +1 -1
- package/dist/maps/gl/store.d.ts +10 -0
- package/dist/maps/gl/store.d.ts.map +1 -1
- package/dist/maps/gl/store.js +47 -4
- package/dist/maps/gl/store.js.map +1 -1
- package/dist/maps/gl/text.d.ts +153 -24
- package/dist/maps/gl/text.d.ts.map +1 -1
- package/dist/maps/gl/text.js +379 -63
- package/dist/maps/gl/text.js.map +1 -1
- package/dist/maps/gl/view.d.ts +293 -2
- package/dist/maps/gl/view.d.ts.map +1 -1
- package/dist/maps/gl/view.js +317 -82
- package/dist/maps/gl/view.js.map +1 -1
- package/dist/maps/icons.d.ts +32 -0
- package/dist/maps/icons.d.ts.map +1 -0
- package/dist/maps/icons.js +151 -0
- package/dist/maps/icons.js.map +1 -0
- package/dist/maps/index.d.ts +2 -0
- package/dist/maps/index.d.ts.map +1 -1
- package/dist/maps/index.js +2 -0
- package/dist/maps/index.js.map +1 -1
- package/dist/maps/labels.d.ts +56 -4
- package/dist/maps/labels.d.ts.map +1 -1
- package/dist/maps/labels.js +213 -46
- package/dist/maps/labels.js.map +1 -1
- package/dist/maps/node.d.ts +17 -0
- package/dist/maps/node.d.ts.map +1 -1
- package/dist/maps/node.js +64 -30
- package/dist/maps/node.js.map +1 -1
- package/dist/maps/proj.d.ts +48 -0
- package/dist/maps/proj.d.ts.map +1 -1
- package/dist/maps/proj.js +73 -0
- package/dist/maps/proj.js.map +1 -1
- package/dist/maps/sources.d.ts +5 -3
- package/dist/maps/sources.d.ts.map +1 -1
- package/dist/maps/sources.js +5 -3
- package/dist/maps/sources.js.map +1 -1
- package/dist/maps/style.d.ts +35 -0
- package/dist/maps/style.d.ts.map +1 -1
- package/dist/maps/style.js.map +1 -1
- package/dist/maps/styles.d.ts +12 -0
- package/dist/maps/styles.d.ts.map +1 -1
- package/dist/maps/styles.js +120 -6
- package/dist/maps/styles.js.map +1 -1
- package/dist/maps/tiles.d.ts +31 -11
- package/dist/maps/tiles.d.ts.map +1 -1
- package/dist/maps/tiles.js +61 -27
- package/dist/maps/tiles.js.map +1 -1
- package/dist/maps/types.d.ts +28 -4
- package/dist/maps/types.d.ts.map +1 -1
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +209 -68
- package/dist/markdown/index.js.map +1 -1
- package/dist/markdown/spans.d.ts.map +1 -1
- package/dist/markdown/spans.js +6 -0
- package/dist/markdown/spans.js.map +1 -1
- package/dist/qml/containers.d.ts.map +1 -1
- package/dist/qml/containers.js +6 -1
- package/dist/qml/containers.js.map +1 -1
- package/dist/qml/interaction.d.ts.map +1 -1
- package/dist/qml/interaction.js +38 -5
- package/dist/qml/interaction.js.map +1 -1
- package/dist/qml/layouts.d.ts.map +1 -1
- package/dist/qml/layouts.js +21 -6
- package/dist/qml/layouts.js.map +1 -1
- package/dist/qml/qtquick.d.ts.map +1 -1
- package/dist/qml/qtquick.js +9 -2
- package/dist/qml/qtquick.js.map +1 -1
- package/dist/qml/react.d.ts.map +1 -1
- package/dist/qml/react.js +9 -7
- package/dist/qml/react.js.map +1 -1
- package/dist/reorder/clock.d.ts +7 -0
- package/dist/reorder/clock.d.ts.map +1 -0
- package/dist/reorder/clock.js +26 -0
- package/dist/reorder/clock.js.map +1 -0
- package/dist/reorder/index.d.ts +25 -3
- package/dist/reorder/index.d.ts.map +1 -1
- package/dist/reorder/index.js +64 -19
- package/dist/reorder/index.js.map +1 -1
- package/dist/rich-text-editor/index.d.ts +0 -11
- package/dist/rich-text-editor/index.d.ts.map +1 -1
- package/dist/rich-text-editor/index.js +38 -9
- package/dist/rich-text-editor/index.js.map +1 -1
- package/dist/rich-text-editor/keymap.d.ts.map +1 -1
- package/dist/rich-text-editor/keymap.js +2 -1
- package/dist/rich-text-editor/keymap.js.map +1 -1
- package/dist/rich-text-editor/keys.d.ts +17 -6
- package/dist/rich-text-editor/keys.d.ts.map +1 -1
- package/dist/rich-text-editor/keys.js +135 -26
- package/dist/rich-text-editor/keys.js.map +1 -1
- package/dist/rich-text-editor/markdown.d.ts.map +1 -1
- package/dist/rich-text-editor/markdown.js +30 -1
- package/dist/rich-text-editor/markdown.js.map +1 -1
- package/dist/rich-text-editor/marks.d.ts +9 -0
- package/dist/rich-text-editor/marks.d.ts.map +1 -0
- package/dist/rich-text-editor/marks.js +83 -0
- package/dist/rich-text-editor/marks.js.map +1 -0
- package/dist/rich-text-editor/render.d.ts.map +1 -1
- package/dist/rich-text-editor/render.js +28 -10
- package/dist/rich-text-editor/render.js.map +1 -1
- package/dist/rich-text-editor/replace.d.ts +13 -0
- package/dist/rich-text-editor/replace.d.ts.map +1 -0
- package/dist/rich-text-editor/replace.js +64 -0
- package/dist/rich-text-editor/replace.js.map +1 -0
- package/dist/rich-text-editor/toolbar.d.ts.map +1 -1
- package/dist/rich-text-editor/toolbar.js +1 -1
- package/dist/rich-text-editor/toolbar.js.map +1 -1
- package/dist/rich-text-editor/virtual.d.ts.map +1 -1
- package/dist/rich-text-editor/virtual.js +17 -0
- package/dist/rich-text-editor/virtual.js.map +1 -1
- package/dist/richtext/node.d.ts +46 -2
- package/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js +187 -4
- package/dist/richtext/node.js.map +1 -1
- package/dist/richtext/runs.d.ts +13 -8
- package/dist/richtext/runs.d.ts.map +1 -1
- package/dist/richtext/runs.js +18 -8
- package/dist/richtext/runs.js.map +1 -1
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +23 -0
- package/dist/table/index.js.map +1 -1
- package/dist/table/rows.d.ts +3 -2
- package/dist/table/rows.d.ts.map +1 -1
- package/dist/table/rows.js +20 -6
- package/dist/table/rows.js.map +1 -1
- package/dist/terminal/index.d.ts.map +1 -1
- package/dist/terminal/index.js +4 -0
- package/dist/terminal/index.js.map +1 -1
- package/dist/terminal/vt/pty.d.ts +13 -2
- package/dist/terminal/vt/pty.d.ts.map +1 -1
- package/dist/terminal/vt/pty.js +18 -3
- package/dist/terminal/vt/pty.js.map +1 -1
- package/dist/terminal-output/index.d.ts.map +1 -1
- package/dist/terminal-output/index.js +103 -23
- package/dist/terminal-output/index.js.map +1 -1
- package/dist/terminal-output/runs.d.ts +4 -1
- package/dist/terminal-output/runs.d.ts.map +1 -1
- package/dist/terminal-output/runs.js +5 -1
- package/dist/terminal-output/runs.js.map +1 -1
- package/dist/three/canvas.d.ts.map +1 -1
- package/dist/three/canvas.js +85 -7
- package/dist/three/canvas.js.map +1 -1
- package/dist/three/events.d.ts +22 -4
- package/dist/three/events.d.ts.map +1 -1
- package/dist/three/events.js +34 -4
- package/dist/three/events.js.map +1 -1
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +17 -0
- package/dist/tree/index.js.map +1 -1
- package/package.json +42 -33
- package/src/charts/node.ts +62 -26
- package/src/charts/render.ts +120 -30
- package/src/code/index.ts +36 -32
- package/src/code-editor/completion.ts +3 -2
- package/src/code-editor/index.ts +19 -0
- package/src/code-editor/node.ts +1574 -184
- package/src/code-language/index.ts +1 -1
- package/src/code-language/languages/glsl.ts +12 -2
- package/src/code-language/languages/javascript.ts +21 -4
- package/src/code-language/languages/json.ts +12 -2
- package/src/code-language/languages/shell.ts +12 -2
- package/src/code-language/languages/sql.ts +22 -2
- package/src/code-language/runs.ts +111 -1
- package/src/code-language/stream.ts +257 -17
- package/src/code-language/types.ts +5 -4
- package/src/codeblock/index.ts +68 -4
- package/src/flow/draw.ts +167 -41
- package/src/flow/gl/color.ts +85 -0
- package/src/flow/gl/index.ts +342 -0
- package/src/flow/gl/pack.ts +585 -0
- package/src/flow/gl/renderer.ts +679 -0
- package/src/flow/gl/shaders.ts +371 -0
- package/src/flow/gl/text.ts +1295 -0
- package/src/flow/index.ts +794 -67
- package/src/flow/node.ts +2496 -1256
- package/src/flow/paint.ts +511 -0
- package/src/flow/paths.ts +80 -15
- package/src/flow/scene.ts +2199 -0
- package/src/flow/types.ts +113 -1
- package/src/html/controls.ts +73 -2
- package/src/html/css/calc.ts +385 -0
- package/src/html/css/cascade.ts +1481 -62
- package/src/html/css/color.ts +755 -0
- package/src/html/css/content.ts +417 -0
- package/src/html/css/counter-styles.ts +973 -0
- package/src/html/css/decode.ts +130 -0
- package/src/html/css/parse.ts +1514 -160
- package/src/html/css/style.ts +4131 -223
- package/src/html/css/ua.ts +66 -17
- package/src/html/css/values.ts +150 -50
- package/src/html/css/vars.ts +348 -0
- package/src/html/dom.ts +284 -17
- package/src/html/fonts.ts +641 -0
- package/src/html/gif.ts +233 -0
- package/src/html/index.ts +180 -20
- package/src/html/layout/block.ts +3560 -208
- package/src/html/layout/boxes.ts +2141 -190
- package/src/html/layout/cache.ts +289 -0
- package/src/html/layout/collapse.ts +317 -0
- package/src/html/layout/css-grid.ts +1063 -0
- package/src/html/layout/flex.ts +1115 -106
- package/src/html/layout/floats.ts +128 -14
- package/src/html/layout/grid-lines.ts +141 -0
- package/src/html/layout/grid.ts +181 -0
- package/src/html/layout/inline.ts +4187 -192
- package/src/html/layout/shaping.ts +140 -0
- package/src/html/layout/table.ts +696 -186
- package/src/html/layout/tracks.ts +437 -0
- package/src/html/node.ts +1585 -156
- package/src/html/paint.ts +4582 -267
- package/src/html/resources.ts +161 -50
- package/src/html/surfaces.ts +81 -0
- package/src/html/svg.ts +504 -0
- package/src/html/url.ts +86 -0
- package/src/index.ts +2 -0
- package/src/internal/codelines.ts +200 -0
- package/src/internal/collate.ts +12 -0
- package/src/internal/markdown/ast.ts +11 -0
- package/src/internal/markdown/parse.ts +154 -11
- package/src/internal/prewarm.ts +45 -0
- package/src/internal/sdf.ts +227 -0
- package/src/internal/splice.ts +25 -0
- package/src/internal/window.ts +36 -5
- package/src/maps/anchors.ts +307 -1
- package/src/maps/controller.ts +116 -31
- package/src/maps/gl/buckets.ts +5 -1
- package/src/maps/gl/build-worker.ts +13 -2
- package/src/maps/gl/cover.ts +79 -44
- package/src/maps/gl/placement.ts +221 -69
- package/src/maps/gl/renderer.ts +104 -20
- package/src/maps/gl/shaders.ts +35 -32
- package/src/maps/gl/store.ts +66 -17
- package/src/maps/gl/text.ts +444 -71
- package/src/maps/gl/view.ts +361 -96
- package/src/maps/icons.ts +183 -0
- package/src/maps/index.ts +4 -0
- package/src/maps/labels.ts +269 -49
- package/src/maps/node.ts +82 -49
- package/src/maps/proj.ts +87 -0
- package/src/maps/sources.ts +5 -3
- package/src/maps/style.ts +32 -0
- package/src/maps/styles.ts +215 -4
- package/src/maps/tiles.ts +64 -31
- package/src/maps/types.ts +28 -4
- package/src/markdown/index.ts +293 -92
- package/src/markdown/spans.ts +5 -0
- package/src/qml/containers.tsx +6 -1
- package/src/qml/interaction.tsx +42 -5
- package/src/qml/layouts.tsx +21 -6
- package/src/qml/qtquick.tsx +9 -2
- package/src/qml/react.tsx +7 -5
- package/src/reorder/clock.ts +27 -0
- package/src/reorder/index.ts +100 -27
- package/src/rich-text-editor/index.ts +37 -12
- package/src/rich-text-editor/keymap.ts +1 -1
- package/src/rich-text-editor/keys.ts +135 -26
- package/src/rich-text-editor/markdown.ts +36 -1
- package/src/rich-text-editor/marks.ts +101 -0
- package/src/rich-text-editor/render.ts +49 -12
- package/src/rich-text-editor/replace.ts +85 -0
- package/src/rich-text-editor/toolbar.ts +1 -1
- package/src/rich-text-editor/virtual.ts +25 -0
- package/src/richtext/node.ts +235 -14
- package/src/richtext/runs.ts +23 -12
- package/src/table/index.ts +29 -0
- package/src/table/rows.ts +18 -8
- package/src/terminal/index.ts +4 -0
- package/src/terminal/vt/pty.ts +16 -2
- package/src/terminal-output/index.ts +129 -31
- package/src/terminal-output/runs.ts +16 -2
- package/src/three/canvas.ts +102 -8
- package/src/three/events.ts +49 -9
- package/src/tree/index.ts +19 -0
|
@@ -25,21 +25,42 @@
|
|
|
25
25
|
// and hands them back on the laid-out runs, so the decoration a `<span>`
|
|
26
26
|
// carries reaches paint on the same object the glyphs did — which is exactly
|
|
27
27
|
// the trick `<richtext>` is built on, and it is why paint reuses richtext's
|
|
28
|
-
// decoration pass unchanged instead of reimplementing it.
|
|
29
|
-
|
|
28
|
+
// decoration pass unchanged instead of reimplementing it. What a run does
|
|
29
|
+
// *not* carry is its element: a layout outlives the parse it was made for
|
|
30
|
+
// (`TextLayoutCache`), so the element under a run is found from its text's
|
|
31
|
+
// place in the document (`LineText.spans`) instead.
|
|
30
32
|
|
|
31
|
-
import { codeUnitOffsets } from '../../internal/text.js';
|
|
33
|
+
import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
|
|
32
34
|
import type { TextRun } from '../../richtext/index.js';
|
|
33
35
|
import type { ComputedStyle } from '../css/style.js';
|
|
34
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
CONTAIN_LAYOUT,
|
|
38
|
+
INLINE_BEFORE_ABSOLUTE,
|
|
39
|
+
scrolls,
|
|
40
|
+
} from '../css/style.js';
|
|
41
|
+
import { inkColor, isTransparent, resolve } from '../css/values.js';
|
|
42
|
+
import {
|
|
43
|
+
BOX_RAISES,
|
|
44
|
+
LINE_BOX_RAISES,
|
|
45
|
+
isOffset,
|
|
46
|
+
letteredAfter,
|
|
47
|
+
SHADOWED_TEXT,
|
|
48
|
+
SHIFTED_LINES,
|
|
49
|
+
TEXT_RAISES,
|
|
50
|
+
transformInPlace,
|
|
51
|
+
} from './boxes.js';
|
|
35
52
|
import type {
|
|
36
53
|
AtomicPlacement,
|
|
37
54
|
Box,
|
|
55
|
+
EdgePlacement,
|
|
56
|
+
FirstLineStyler,
|
|
57
|
+
InlineDecoration,
|
|
38
58
|
LineBox,
|
|
39
59
|
LineText,
|
|
40
60
|
TextLayoutLike,
|
|
41
61
|
} from './boxes.js';
|
|
42
62
|
import type { FloatContext } from './floats.js';
|
|
63
|
+
import { tableGrid } from './grid.js';
|
|
43
64
|
|
|
44
65
|
/** The slice of ntk's font manager this needs. Structural, as everywhere. */
|
|
45
66
|
export interface FontsLike {
|
|
@@ -52,6 +73,15 @@ export interface FontsLike {
|
|
|
52
73
|
align?: string;
|
|
53
74
|
direction?: string;
|
|
54
75
|
maxLines?: number;
|
|
76
|
+
/** What a `maxLines` cut looks like: an ellipsis, or nothing. */
|
|
77
|
+
overflow?: 'clip' | 'ellipsis';
|
|
78
|
+
/** A word too long for its line kept whole, past the line's end, or
|
|
79
|
+
* cut inside itself — ntk's option, which an engine without it
|
|
80
|
+
* leaves at cutting. */
|
|
81
|
+
overflowWrap?: 'normal' | 'break-word';
|
|
82
|
+
/** Whether soft wraps are made at all: false is a line to each
|
|
83
|
+
* forced break, cut at `maxWidth` where `overflow` says so. ntk's. */
|
|
84
|
+
wrap?: boolean;
|
|
55
85
|
},
|
|
56
86
|
): TextLayoutLike;
|
|
57
87
|
match(
|
|
@@ -78,6 +108,128 @@ export interface FontsLike {
|
|
|
78
108
|
*/
|
|
79
109
|
const LAYOUT_RUNS = new WeakMap<TextLayoutLike, TextRun[] | number[]>();
|
|
80
110
|
|
|
111
|
+
const WIDEST_WORD = new WeakMap<TextLayoutLike, number>();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The widest word between spaces in a layout's text: its runs laid out
|
|
115
|
+
* again with every space a line break, at no width limit. Where a text's
|
|
116
|
+
* only breaks are spaces that is its min-content width, and where it has
|
|
117
|
+
* others — a hyphen, a slash — more: an upper bound of it either way. The
|
|
118
|
+
* other way to ask, a layout of no width, has ntk search every word for a
|
|
119
|
+
* place to cut it, since none fits, which costs fifteen layouts of this
|
|
120
|
+
* one. Infinity where the runs are no longer kept.
|
|
121
|
+
*/
|
|
122
|
+
export function widestWord(
|
|
123
|
+
fonts: FontsLike,
|
|
124
|
+
layout: TextLayoutLike,
|
|
125
|
+
measured = true,
|
|
126
|
+
): number {
|
|
127
|
+
if (!measured) return longestWord(layout);
|
|
128
|
+
let widest = WIDEST_WORD.get(layout);
|
|
129
|
+
if (widest !== undefined) return widest;
|
|
130
|
+
const held = LAYOUT_RUNS.get(layout);
|
|
131
|
+
if (!held || (held.length > 0 && typeof held[0] === 'number')) {
|
|
132
|
+
return Infinity;
|
|
133
|
+
}
|
|
134
|
+
const words = fonts.layout(
|
|
135
|
+
(held as TextRun[]).map((run) => ({
|
|
136
|
+
...run,
|
|
137
|
+
text: run.text.replace(/[ \t\u200b]+/g, '\n'),
|
|
138
|
+
})),
|
|
139
|
+
{},
|
|
140
|
+
{},
|
|
141
|
+
);
|
|
142
|
+
widest = 0;
|
|
143
|
+
for (const line of words.lines) widest = Math.max(widest, line.width);
|
|
144
|
+
WIDEST_WORD.set(layout, widest);
|
|
145
|
+
return widest;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const SPACE_BREAKS = new WeakMap<TextLayoutLike, boolean>();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether a layout's text breaks only at its spaces: letters and digits of
|
|
152
|
+
* the Latin, Greek and Cyrillic scripts, and the punctuation that UAX #14
|
|
153
|
+
* never breaks after between two letters — no hyphen, slash, `!`, `?`,
|
|
154
|
+
* `|`, `}`, soft hyphen or ellipsis, and nothing of a script that breaks
|
|
155
|
+
* between its letters or by dictionary. Where it does, its widest word
|
|
156
|
+
* (`widestWord`) is exactly its min-content width.
|
|
157
|
+
*/
|
|
158
|
+
export function spaceBreaksOnly(layout: TextLayoutLike): boolean {
|
|
159
|
+
let only = SPACE_BREAKS.get(layout);
|
|
160
|
+
if (only !== undefined) return only;
|
|
161
|
+
const held = LAYOUT_RUNS.get(layout);
|
|
162
|
+
only = !!held && (held.length === 0 || typeof held[0] !== 'number');
|
|
163
|
+
if (only) {
|
|
164
|
+
outer: for (const run of held as TextRun[]) {
|
|
165
|
+
const text = run.text;
|
|
166
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
167
|
+
if (!breaksOnlyAtSpace(text.charCodeAt(i))) {
|
|
168
|
+
only = false;
|
|
169
|
+
break outer;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
SPACE_BREAKS.set(layout, only);
|
|
175
|
+
return only;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function breaksOnlyAtSpace(c: number): boolean {
|
|
179
|
+
if (c < 0x80) {
|
|
180
|
+
// `-` `/` `!` `?` `|` `}`: a break may follow each
|
|
181
|
+
return (
|
|
182
|
+
c >= 0x20 &&
|
|
183
|
+
c !== 0x2d &&
|
|
184
|
+
c !== 0x2f &&
|
|
185
|
+
c !== 0x21 &&
|
|
186
|
+
c !== 0x3f &&
|
|
187
|
+
c !== 0x7c &&
|
|
188
|
+
c !== 0x7d &&
|
|
189
|
+
c !== 0x7f
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// Latin-1 but the soft hyphen and the acute accent, which a break may
|
|
193
|
+
// precede; Latin Extended, the combining marks, Greek and Cyrillic
|
|
194
|
+
if (c >= 0xa0 && c < 0x530) return c !== 0xad && c !== 0xb4 && c !== 0x2c8;
|
|
195
|
+
// the curly quotes
|
|
196
|
+
return c >= 0x2018 && c <= 0x201f;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const LONGEST_WORD = new WeakMap<TextLayoutLike, number>();
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `widestWord` estimated from the text alone, with no layout: each word's
|
|
203
|
+
* characters at half again their size, which no glyph a document sets
|
|
204
|
+
* words in is wider than, and their letter spacing. What is under it
|
|
205
|
+
* needs no measuring, and most words are.
|
|
206
|
+
*/
|
|
207
|
+
function longestWord(layout: TextLayoutLike): number {
|
|
208
|
+
let longest = LONGEST_WORD.get(layout);
|
|
209
|
+
if (longest !== undefined) return longest;
|
|
210
|
+
const held = LAYOUT_RUNS.get(layout);
|
|
211
|
+
if (!held || (held.length > 0 && typeof held[0] === 'number')) {
|
|
212
|
+
return Infinity;
|
|
213
|
+
}
|
|
214
|
+
longest = 0;
|
|
215
|
+
let word = 0;
|
|
216
|
+
for (const run of held as TextRun[]) {
|
|
217
|
+
const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
|
|
218
|
+
const text = run.text;
|
|
219
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
220
|
+
const c = text.charCodeAt(i);
|
|
221
|
+
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x200b) word = 0;
|
|
222
|
+
else if (c < 0xdc00 || c > 0xdfff) {
|
|
223
|
+
// a surrogate pair is one character
|
|
224
|
+
word += per;
|
|
225
|
+
if (word > longest) longest = word;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
LONGEST_WORD.set(layout, longest);
|
|
230
|
+
return longest;
|
|
231
|
+
}
|
|
232
|
+
|
|
81
233
|
/** Code-unit → code-point offsets for a layout's text, built once. */
|
|
82
234
|
export function layoutOffsets(layout: TextLayoutLike): number[] {
|
|
83
235
|
const held = LAYOUT_RUNS.get(layout);
|
|
@@ -92,16 +244,44 @@ export function layoutOffsets(layout: TextLayoutLike): number[] {
|
|
|
92
244
|
|
|
93
245
|
const EMPTY_OFFSETS: number[] = [0];
|
|
94
246
|
|
|
95
|
-
/** One thing in the inline stream.
|
|
247
|
+
/** One thing in the inline stream. An `edge` is an inline box's own margin,
|
|
248
|
+
* border and padding on one side — before its first fragment, or after its
|
|
249
|
+
* last (CSS 2.1 8.x, 10.3.1) — which takes room on the line and draws no
|
|
250
|
+
* glyph. */
|
|
96
251
|
type Item =
|
|
97
|
-
| {
|
|
98
|
-
|
|
252
|
+
| {
|
|
253
|
+
kind: 'text';
|
|
254
|
+
run: TextRun;
|
|
255
|
+
box: Box;
|
|
256
|
+
length: number;
|
|
257
|
+
/** Where the run's text starts in the document index: its box's
|
|
258
|
+
* start, or further in, where the box's text is split. */
|
|
259
|
+
start: number;
|
|
260
|
+
/** A bidi control `unicode-bidi` stands for (`bidiControls`): laid
|
|
261
|
+
* out, and no text of the document's. */
|
|
262
|
+
control?: true;
|
|
263
|
+
}
|
|
264
|
+
| { kind: 'atomic'; box: Box }
|
|
265
|
+
| { kind: 'edge'; box: Box; side: 'start' | 'end'; width: number }
|
|
266
|
+
/** A float, where it is in the content: placed when the lines reach it
|
|
267
|
+
* (`InlineOptions.floatBoxes`). */
|
|
268
|
+
| { kind: 'float'; box: Box };
|
|
269
|
+
|
|
270
|
+
function isText(item: Item): item is Extract<Item, { kind: 'text' }> {
|
|
271
|
+
return item.kind === 'text';
|
|
272
|
+
}
|
|
99
273
|
|
|
100
274
|
export interface InlineResult {
|
|
101
275
|
lines: LineBox[];
|
|
102
276
|
height: number;
|
|
103
277
|
/** The widest line — what a shrink-to-fit width takes. */
|
|
104
278
|
width: number;
|
|
279
|
+
/** The widest row of floats the paragraph placed beside none of its
|
|
280
|
+
* lines — all of them, where it has nothing else — side by side. */
|
|
281
|
+
floatRow?: number;
|
|
282
|
+
/** Whether a clamp cut the text short: there was more of it than the
|
|
283
|
+
* lines it left (`InlineOptions.clamp`). */
|
|
284
|
+
cut?: boolean;
|
|
105
285
|
}
|
|
106
286
|
|
|
107
287
|
export interface InlineOptions {
|
|
@@ -113,6 +293,39 @@ export interface InlineOptions {
|
|
|
113
293
|
floats: FloatContext | null;
|
|
114
294
|
/** The block's content-box left edge, in the float context's space. */
|
|
115
295
|
originX: number;
|
|
296
|
+
/** A `::first-line` colour, for the text before `end` — the first line's
|
|
297
|
+
* end, in the document index — whose colour is `from`, the block's. */
|
|
298
|
+
firstLine?: { color: string; from: string; end: number };
|
|
299
|
+
/**
|
|
300
|
+
* A `::first-line` that sets the first line's fonts, which move where it
|
|
301
|
+
* breaks: its style, and each box's style on the line (`FirstLineStyler`)
|
|
302
|
+
* — which the line is found in, a piece at a time (CSS 2.1 5.12.1).
|
|
303
|
+
*/
|
|
304
|
+
firstLineStyle?: { style: ComputedStyle; styler: FirstLineStyler };
|
|
305
|
+
/** Room the first line has to hold about its baseline besides the
|
|
306
|
+
* strut's: a list item's marker (`MARKER_ROOM`). */
|
|
307
|
+
firstStrut?: InlineDecoration;
|
|
308
|
+
/** Whether any box in the document paints its background through its
|
|
309
|
+
* text (`BoxTree.clipText`). */
|
|
310
|
+
clipText?: boolean;
|
|
311
|
+
/**
|
|
312
|
+
* The lines a line-clamp container leaves the block (CSS Overflow 4,
|
|
313
|
+
* 5.3.1): no more than `lines` of them show, and the last of a cut ends
|
|
314
|
+
* in an ellipsis where `ellipsis` says — as it does where they are all
|
|
315
|
+
* the block has and more of the container `follows` them, the clamp
|
|
316
|
+
* point just after them (4.2).
|
|
317
|
+
*/
|
|
318
|
+
clamp?: { lines: number; ellipsis: boolean; follows: boolean };
|
|
319
|
+
/**
|
|
320
|
+
* The floats in the content, placed as the lines reach them: `size` lays
|
|
321
|
+
* one out and answers its outer width, `place` puts it at a height in the
|
|
322
|
+
* float context's space. Absent, the floats are not the lines' to place —
|
|
323
|
+
* they were, by an earlier pass over the same lines.
|
|
324
|
+
*/
|
|
325
|
+
floatBoxes?: {
|
|
326
|
+
size(box: Box): number;
|
|
327
|
+
place(box: Box, y: number): void;
|
|
328
|
+
};
|
|
116
329
|
}
|
|
117
330
|
|
|
118
331
|
/**
|
|
@@ -120,12 +333,155 @@ export interface InlineOptions {
|
|
|
120
333
|
* relative to the containing block's content box; the caller translates.
|
|
121
334
|
*/
|
|
122
335
|
export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
123
|
-
const
|
|
336
|
+
const result = layoutLines(block, options);
|
|
337
|
+
const clamp = options.clamp?.lines;
|
|
338
|
+
// A clamped block shows its first lines and is as tall as they are. The
|
|
339
|
+
// one-layout paths below had the engine cut them, with its ellipsis; a
|
|
340
|
+
// block laid out a line at a time — an image on a line, a float beside
|
|
341
|
+
// it — is cut here, and ends where its last line does, with none.
|
|
342
|
+
if (clamp !== undefined && result.lines.length > clamp) {
|
|
343
|
+
const lines = result.lines.slice(0, clamp);
|
|
344
|
+
const last = lines[clamp - 1];
|
|
345
|
+
let widest = 0;
|
|
346
|
+
for (const line of lines) widest = Math.max(widest, line.width);
|
|
347
|
+
return {
|
|
348
|
+
lines,
|
|
349
|
+
height: last ? last.y + last.height : 0,
|
|
350
|
+
width: widest,
|
|
351
|
+
cut: true,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The cut a block's text is laid out with, as the engine takes it: the
|
|
359
|
+
* lines a line-clamp container leaves it, or the one line of a
|
|
360
|
+
* `white-space: nowrap` block
|
|
361
|
+
* that clips with `text-overflow: ellipsis` — Tailwind's `truncate`, cut at
|
|
362
|
+
* the box's width with an ellipsis. That line is laid out unwrapped and
|
|
363
|
+
* cut where the box ends, inside a word if need be, as a browser cuts it,
|
|
364
|
+
* by an engine that reads `wrap` (ntk from 8.13.0); one that does not
|
|
365
|
+
* wraps it first and ends it after the word that fits.
|
|
366
|
+
*/
|
|
367
|
+
/** A layout cut at a number of lines, with an ellipsis or with none. */
|
|
368
|
+
interface Cut {
|
|
369
|
+
maxLines: number;
|
|
370
|
+
overflow: 'clip' | 'ellipsis';
|
|
371
|
+
wrap?: false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cutOf(
|
|
375
|
+
style: ComputedStyle,
|
|
376
|
+
clamp: InlineOptions['clamp'],
|
|
377
|
+
): Cut | null {
|
|
378
|
+
// the ellipsis a clamp ends in is placed apart (`ellipsized`)
|
|
379
|
+
if (clamp) return { maxLines: clamp.lines, overflow: 'clip' };
|
|
380
|
+
if (
|
|
381
|
+
style.textOverflow === 'ellipsis' &&
|
|
382
|
+
!wraps(style) &&
|
|
383
|
+
style.overflowX !== 'visible'
|
|
384
|
+
) {
|
|
385
|
+
return { maxLines: 1, overflow: 'ellipsis', wrap: false };
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function layoutLines(block: Box, options: InlineOptions): InlineResult {
|
|
124
391
|
const items: Item[] = [];
|
|
125
|
-
|
|
126
|
-
|
|
392
|
+
const statics: StaticMark[] = [];
|
|
393
|
+
const floatCount = collect(
|
|
394
|
+
block,
|
|
395
|
+
items,
|
|
396
|
+
options.width,
|
|
397
|
+
options.fonts,
|
|
398
|
+
block.style,
|
|
399
|
+
!!options.floatBoxes,
|
|
400
|
+
statics,
|
|
401
|
+
);
|
|
402
|
+
const result = linesOf(block, options, items, floatCount);
|
|
403
|
+
// whichever way the lines were made, one pass over them
|
|
404
|
+
if (statics.length) {
|
|
405
|
+
staticPositions(statics, items, result.lines, block, options);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The lines of a block's inline content, gathered into `items`. */
|
|
411
|
+
function linesOf(
|
|
412
|
+
block: Box,
|
|
413
|
+
options: InlineOptions,
|
|
414
|
+
items: Item[],
|
|
415
|
+
floatCount: number,
|
|
416
|
+
): InlineResult {
|
|
417
|
+
let fonts = options.fonts;
|
|
418
|
+
const placing = options.floatBoxes;
|
|
419
|
+
if (items.length === floatCount || !fonts) {
|
|
420
|
+
// no line to wait for: the floats go at the top
|
|
421
|
+
const placed: Box[] = [];
|
|
422
|
+
for (const item of items) {
|
|
423
|
+
if (item.kind === 'float') {
|
|
424
|
+
placing!.size(item.box);
|
|
425
|
+
placing!.place(item.box, options.startY);
|
|
426
|
+
placed.push(item.box);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (!placed.length) return EMPTY;
|
|
430
|
+
return { ...EMPTY, floatRow: besideLines([], placed, options.startY) };
|
|
431
|
+
}
|
|
432
|
+
// text a box's background shows through (`background-clip: text`): its
|
|
433
|
+
// layouts are made through a recorder, so paint can lay the same text out
|
|
434
|
+
// again with no ink of its own and fill it with that background
|
|
435
|
+
if (options.clipText && clipsText(block, items)) {
|
|
436
|
+
CLIPPED_TEXT.add(block);
|
|
437
|
+
fonts = recording(fonts);
|
|
438
|
+
}
|
|
439
|
+
// small capitals the face does not have, made of its capitals
|
|
440
|
+
synthesizeSmallCaps(items, fonts);
|
|
441
|
+
if (wraps(block.style)) holdNoWrap(items, block);
|
|
442
|
+
// one walk for what few paragraphs have: text that casts a shadow, and a
|
|
443
|
+
// tab, which only a `white-space` that keeps it leaves — so no text is
|
|
444
|
+
// searched for one where it cannot be
|
|
445
|
+
let tabbed = false;
|
|
446
|
+
let shadowed = false;
|
|
447
|
+
for (const item of items) {
|
|
448
|
+
if (item.kind !== 'text' || item.control) continue;
|
|
449
|
+
const style = item.box.style;
|
|
450
|
+
if (style.textShadow) shadowed = true;
|
|
451
|
+
if (
|
|
452
|
+
!tabbed &&
|
|
453
|
+
(style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
|
|
454
|
+
item.run.text.includes('\t')
|
|
455
|
+
) {
|
|
456
|
+
tabbed = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (shadowed) SHADOWED_TEXT.add(block);
|
|
460
|
+
if (tabbed) {
|
|
461
|
+
setTabs(items, fonts, block.style, blockIndent(block, options.width));
|
|
462
|
+
}
|
|
463
|
+
// an override on the block is one on all of its inline content (CSS 2.1
|
|
464
|
+
// 9.10); its embedding or isolation is the paragraph's own direction
|
|
465
|
+
const own =
|
|
466
|
+
block.style.unicodeBidi === 'bidi-override' ||
|
|
467
|
+
block.style.unicodeBidi === 'isolate-override'
|
|
468
|
+
? bidiControls(block.style, true)
|
|
469
|
+
: null;
|
|
470
|
+
if (own) {
|
|
471
|
+
const first = items.find(isText);
|
|
472
|
+
let last: Extract<Item, { kind: 'text' }> | undefined;
|
|
473
|
+
for (const item of items) if (item.kind === 'text') last = item;
|
|
474
|
+
if (first && last) {
|
|
475
|
+
const opening: Item[] = [];
|
|
476
|
+
pushControls(opening, own[0], block, first.start);
|
|
477
|
+
items.unshift(...opening);
|
|
478
|
+
pushControls(items, own[1], block, last.start + last.length);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (options.firstLine) firstLineColour(items, options.firstLine);
|
|
127
482
|
|
|
128
483
|
const style = block.style;
|
|
484
|
+
const wrapWords = overflowWrapOf(style, items);
|
|
129
485
|
const base = {
|
|
130
486
|
family: style.fontFamily,
|
|
131
487
|
size: style.fontSize,
|
|
@@ -134,16 +490,98 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
134
490
|
color: style.color,
|
|
135
491
|
};
|
|
136
492
|
const lineHeightMul = lineHeightMultiplier(fonts, style);
|
|
137
|
-
|
|
138
|
-
|
|
493
|
+
// The lines the end of the text or a forced break ends take
|
|
494
|
+
// `text-align-last` (CSS Text 3, 7.2), and a text that does not wrap is
|
|
495
|
+
// all such lines. Where they are aligned otherwise than the rest, and
|
|
496
|
+
// neither is justified — which fills a line whatever its alignment — the
|
|
497
|
+
// lines are made one at a time, each aligned its own way.
|
|
498
|
+
const lastAlign = lastAlignOf(style);
|
|
499
|
+
const align = engineAlign(wraps(style) ? style.textAlign : lastAlign);
|
|
500
|
+
const rtl = style.direction === 'rtl';
|
|
501
|
+
const alignedApart =
|
|
502
|
+
wraps(style) &&
|
|
503
|
+
style.textAlign !== 'justify' &&
|
|
504
|
+
lastAlign !== 'justify' &&
|
|
505
|
+
alignShift(style.textAlign, rtl) !== alignShift(lastAlign, rtl);
|
|
506
|
+
const indent = blockIndent(block, options.width);
|
|
507
|
+
|
|
508
|
+
let hasAtomics = false;
|
|
509
|
+
let hasEdges = false;
|
|
510
|
+
let hasOffset = false;
|
|
511
|
+
let raised = false;
|
|
512
|
+
for (const item of items) {
|
|
513
|
+
if (item.kind === 'atomic') hasAtomics = true;
|
|
514
|
+
else if (item.kind === 'text') {
|
|
515
|
+
// text in an inline box with a line height of its own, which an
|
|
516
|
+
// inline box of no edges has as much as one with them
|
|
517
|
+
if (!raised && fonts && inBoxOwningLeading(fonts, block, item.box)) {
|
|
518
|
+
hasOffset = true;
|
|
519
|
+
raised = true;
|
|
520
|
+
}
|
|
521
|
+
} else if (item.kind === 'edge') {
|
|
522
|
+
hasEdges = true;
|
|
523
|
+
if (
|
|
524
|
+
item.box.style.verticalAlign !== 'baseline' ||
|
|
525
|
+
ownsLeading(fonts, block.style, item.box.style) ||
|
|
526
|
+
tallerStrut(fonts, block.style, item.box)
|
|
527
|
+
) {
|
|
528
|
+
hasOffset = true;
|
|
529
|
+
raised = true;
|
|
530
|
+
} else if (!hasOffset && isOffset(item.box.style)) hasOffset = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// a float in the content is placed on the line it is on, which only the
|
|
534
|
+
// line-at-a-time loop knows, as it does whatever floats are beside it
|
|
535
|
+
const floated =
|
|
536
|
+
floatCount > 0 ||
|
|
537
|
+
(options.floats?.intersects(options.startY, Infinity) ?? false);
|
|
538
|
+
// An inline box's edges take room on their lines. Where nothing else on
|
|
539
|
+
// the lines has to be placed a piece at a time, they go into the one
|
|
540
|
+
// layout as spacers (`spacerRun`) — which only holds where no bidi
|
|
541
|
+
// reordering can move one off the side of its box it belongs to, and no
|
|
542
|
+
// box between edges is moved afterwards, which needs its text apart.
|
|
543
|
+
const spaced =
|
|
544
|
+
hasEdges && !hasAtomics && !hasOffset && spacersHold(style, items);
|
|
545
|
+
// a first line in fonts of its own is found a piece at a time, in them
|
|
546
|
+
const restyled = options.firstLineStyle !== undefined;
|
|
547
|
+
// and where one layout makes a line shorter than the strut, its text all
|
|
548
|
+
// smaller than the block's, the lines are made a piece at a time too,
|
|
549
|
+
// where every line holds it (`shortOfStrut`)
|
|
550
|
+
const strut = fonts ? strutOf(fonts, style) : null;
|
|
551
|
+
// and the first line holds what it is handed besides: a marker's room
|
|
552
|
+
const firstStrut = withRoom(strut, options.firstStrut);
|
|
553
|
+
let strutted = false;
|
|
139
554
|
|
|
140
|
-
|
|
141
|
-
|
|
555
|
+
// Inline boxes' edges as spacers in one layout, where the engine left
|
|
556
|
+
// every edge on the line of the content it belongs to (`layoutSpaced`);
|
|
557
|
+
// the lines a piece at a time, below, where it did not.
|
|
558
|
+
if (spaced && !floated && !indent && !alignedApart && !restyled) {
|
|
559
|
+
const laid = layoutSpaced(
|
|
560
|
+
items,
|
|
561
|
+
base,
|
|
562
|
+
style,
|
|
563
|
+
options.width,
|
|
564
|
+
lineHeightMul,
|
|
565
|
+
align,
|
|
566
|
+
fonts,
|
|
567
|
+
);
|
|
568
|
+
if (laid && !shortOfStrut(laid.lines, strut, firstStrut)) return laid;
|
|
569
|
+
if (laid) strutted = true;
|
|
570
|
+
}
|
|
142
571
|
|
|
143
572
|
// The text-only, float-free, unindented case: one call, every line — or
|
|
144
573
|
// one call per *chunk*, when the text is long and carries hard breaks.
|
|
145
|
-
if (
|
|
146
|
-
|
|
574
|
+
fast: if (
|
|
575
|
+
!hasAtomics &&
|
|
576
|
+
!hasEdges &&
|
|
577
|
+
!raised &&
|
|
578
|
+
!floated &&
|
|
579
|
+
!indent &&
|
|
580
|
+
!alignedApart &&
|
|
581
|
+
!restyled &&
|
|
582
|
+
!strutted
|
|
583
|
+
) {
|
|
584
|
+
// no atomics and no edges, so everything is text
|
|
147
585
|
const textItems = items as Extract<Item, { kind: 'text' }>[];
|
|
148
586
|
let total = 0;
|
|
149
587
|
let hasNewline = false;
|
|
@@ -152,7 +590,15 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
152
590
|
if (!hasNewline && item.run.text.includes('\n')) hasNewline = true;
|
|
153
591
|
}
|
|
154
592
|
if (!total) return EMPTY;
|
|
155
|
-
|
|
593
|
+
// `text-overflow` cuts every line that overflows, and a layout can cut
|
|
594
|
+
// only its last: one that a forced break ends is laid out apart, a hard
|
|
595
|
+
// line at a time, or the lines after the first were lost. A clamp is
|
|
596
|
+
// the paragraph's, and stays one layout.
|
|
597
|
+
const clamp = options.clamp;
|
|
598
|
+
let cut = cutOf(style, clamp);
|
|
599
|
+
const perLine = cut !== null && !clamp && hasNewline;
|
|
600
|
+
// an embedding cannot be cut into chunks
|
|
601
|
+
if (perLine && !hasControls(items)) {
|
|
156
602
|
return layoutChunked(
|
|
157
603
|
textItems,
|
|
158
604
|
base,
|
|
@@ -161,24 +607,131 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
161
607
|
lineHeightMul,
|
|
162
608
|
align,
|
|
163
609
|
fonts,
|
|
610
|
+
{ chars: Infinity, hardLines: 1, cut },
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (perLine) cut = null;
|
|
614
|
+
if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
|
|
615
|
+
const chunked = layoutChunked(
|
|
616
|
+
textItems,
|
|
617
|
+
base,
|
|
618
|
+
style,
|
|
619
|
+
options.width,
|
|
620
|
+
lineHeightMul,
|
|
621
|
+
align,
|
|
622
|
+
fonts,
|
|
164
623
|
);
|
|
624
|
+
if (!shortOfStrut(chunked.lines, strut, firstStrut)) return chunked;
|
|
625
|
+
strutted = true;
|
|
626
|
+
break fast;
|
|
165
627
|
}
|
|
166
|
-
|
|
167
|
-
|
|
628
|
+
let runs: TextRun[] = [];
|
|
629
|
+
let spans = new SpanMap();
|
|
168
630
|
for (const item of textItems) {
|
|
169
|
-
spans.add(
|
|
631
|
+
spans.add(
|
|
632
|
+
item.start,
|
|
633
|
+
item.run.text.length,
|
|
634
|
+
item.control ? null : item.box,
|
|
635
|
+
);
|
|
170
636
|
runs.push(item.run);
|
|
171
637
|
}
|
|
172
|
-
|
|
173
|
-
|
|
638
|
+
// justified lines fill their width, so where all but the last are,
|
|
639
|
+
// the layout is aligned as those are
|
|
640
|
+
const justifies = wraps(style) && style.textJustify !== 'none';
|
|
641
|
+
const justifyRest = style.textAlign === 'justify' && justifies;
|
|
642
|
+
const justifyLast = lastAlign === 'justify' && justifies;
|
|
643
|
+
const layoutOptions = {
|
|
644
|
+
maxWidth: wraps(style) || cut ? options.width : undefined,
|
|
174
645
|
lineHeight: lineHeightMul,
|
|
175
|
-
align,
|
|
646
|
+
align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
|
|
176
647
|
direction: style.direction,
|
|
177
|
-
|
|
648
|
+
overflowWrap: cutWrap(style, cut, wrapWords),
|
|
649
|
+
...cut,
|
|
650
|
+
};
|
|
651
|
+
let layout = fonts.layout(runs, base, layoutOptions);
|
|
178
652
|
LAYOUT_RUNS.set(layout, runs);
|
|
653
|
+
// A clamp that cut the text short says so, for the clamp point after
|
|
654
|
+
// it, and its last line ends in an ellipsis — as it does where the
|
|
655
|
+
// clamp point falls just after the lines, more of the container after
|
|
656
|
+
// them (CSS Overflow 4, 4.2).
|
|
657
|
+
let clampCut = false;
|
|
658
|
+
if (clamp) {
|
|
659
|
+
const shown = layout.lines.length;
|
|
660
|
+
clampCut =
|
|
661
|
+
shown > 0 &&
|
|
662
|
+
shown >= clamp.lines &&
|
|
663
|
+
(layout.truncated ?? inkBeyond(runs, layout.lines[shown - 1].end));
|
|
664
|
+
if (
|
|
665
|
+
clamp.ellipsis &&
|
|
666
|
+
shown > 0 &&
|
|
667
|
+
shown === clamp.lines &&
|
|
668
|
+
(clampCut || clamp.follows)
|
|
669
|
+
) {
|
|
670
|
+
const ended = ellipsized(
|
|
671
|
+
fonts,
|
|
672
|
+
textItems,
|
|
673
|
+
runs,
|
|
674
|
+
base,
|
|
675
|
+
layoutOptions,
|
|
676
|
+
layout,
|
|
677
|
+
options.width,
|
|
678
|
+
style,
|
|
679
|
+
);
|
|
680
|
+
layout = ended.layout;
|
|
681
|
+
runs = ended.runs;
|
|
682
|
+
spans = ended.spans;
|
|
683
|
+
LAYOUT_RUNS.set(layout, runs);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// `text-wrap: balance`: the lines broken at the narrowest width that
|
|
687
|
+
// keeps as many of them, and then set in the whole width
|
|
688
|
+
let balanced = false;
|
|
689
|
+
if (
|
|
690
|
+
style.textWrapStyle === 'balance' &&
|
|
691
|
+
layoutOptions.maxWidth !== undefined &&
|
|
692
|
+
!cut &&
|
|
693
|
+
style.textAlign !== 'justify' &&
|
|
694
|
+
layout.lines.length > 1 &&
|
|
695
|
+
layout.lines.length <= BALANCE_LINES
|
|
696
|
+
) {
|
|
697
|
+
const width = layoutOptions.maxWidth;
|
|
698
|
+
const at = balancedWidth(fonts, runs, base, layoutOptions, width, layout);
|
|
699
|
+
if (at < width) {
|
|
700
|
+
layout = fonts.layout(runs, base, { ...layoutOptions, maxWidth: at });
|
|
701
|
+
LAYOUT_RUNS.set(layout, runs);
|
|
702
|
+
balanced = true;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
layout = justifiedLayout(
|
|
706
|
+
fonts,
|
|
707
|
+
runs,
|
|
708
|
+
base,
|
|
709
|
+
layoutOptions,
|
|
710
|
+
layout,
|
|
711
|
+
options.width,
|
|
712
|
+
{ rest: justifyRest, last: justifyLast },
|
|
713
|
+
).layout;
|
|
179
714
|
const lines: LineBox[] = [];
|
|
180
|
-
const widest = emitLayout(
|
|
181
|
-
|
|
715
|
+
const widest = emitLayout(
|
|
716
|
+
layout,
|
|
717
|
+
spans,
|
|
718
|
+
0,
|
|
719
|
+
0,
|
|
720
|
+
lines,
|
|
721
|
+
layoutOptions.maxWidth === undefined
|
|
722
|
+
? unwrappedPlacer(style, options.width, true)
|
|
723
|
+
: balanced
|
|
724
|
+
? unwrappedPlacer(style, options.width, false)
|
|
725
|
+
: null,
|
|
726
|
+
);
|
|
727
|
+
// a clamp and a cut are the one layout's to make
|
|
728
|
+
if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
|
|
729
|
+
strutted = true;
|
|
730
|
+
break fast;
|
|
731
|
+
}
|
|
732
|
+
return clampCut
|
|
733
|
+
? { lines, height: layout.height, width: widest, cut: true }
|
|
734
|
+
: { lines, height: layout.height, width: widest };
|
|
182
735
|
}
|
|
183
736
|
|
|
184
737
|
// --- the general case: line at a time -------------------------------------
|
|
@@ -188,38 +741,237 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
188
741
|
let index = 0;
|
|
189
742
|
let offset = 0;
|
|
190
743
|
let open = openLine(indent);
|
|
744
|
+
/** Start edges waiting for the content they belong to: an inline box's
|
|
745
|
+
* opening margin, border and padding go on the line its first content
|
|
746
|
+
* goes on, so they are placed with it, never left at a line's end. */
|
|
747
|
+
let pending: Extract<Item, { kind: 'edge' }>[] = [];
|
|
748
|
+
let pendingWidth = 0;
|
|
749
|
+
const placePending = (left: number): void => {
|
|
750
|
+
if (pending.length) open.left = left;
|
|
751
|
+
for (const edge of pending) {
|
|
752
|
+
const placed: EdgePlacement = {
|
|
753
|
+
box: edge.box,
|
|
754
|
+
side: 'start',
|
|
755
|
+
x: left + open.x,
|
|
756
|
+
width: edge.width,
|
|
757
|
+
};
|
|
758
|
+
open.edges.push(placed);
|
|
759
|
+
open.order.push({ kind: 'edge', at: open.x, item: placed });
|
|
760
|
+
open.x += edge.width;
|
|
761
|
+
}
|
|
762
|
+
pending = [];
|
|
763
|
+
pendingWidth = 0;
|
|
764
|
+
};
|
|
765
|
+
/** Floats met on a line they did not fit beside: they go at the top of
|
|
766
|
+
* the next line, as the line they were met on closes. */
|
|
767
|
+
let deferred: Box[] = [];
|
|
768
|
+
/** Every float placed, in order, for the lines they stand beside. */
|
|
769
|
+
const floatsPlaced: Box[] = [];
|
|
770
|
+
const placeDeferred = (): void => {
|
|
771
|
+
for (const box of deferred) {
|
|
772
|
+
options.floatBoxes!.place(box, options.startY + y);
|
|
773
|
+
floatsPlaced.push(box);
|
|
774
|
+
}
|
|
775
|
+
deferred = [];
|
|
776
|
+
};
|
|
191
777
|
|
|
192
|
-
|
|
193
|
-
|
|
778
|
+
// How tall a line is taken to be before it is made, for its room beside
|
|
779
|
+
// the floats: the paragraph's strut, a line of its text. An item taller
|
|
780
|
+
// asks over its own height, and the line made over its whole (`close`).
|
|
781
|
+
// A 1.4em guess put a line of `line-height: 0` below floats it fitted
|
|
782
|
+
// beside.
|
|
783
|
+
const guess = strut ? strut.ascent + strut.descent : style.fontSize * 1.4;
|
|
784
|
+
const lifts = raised && fonts ? new Lifts(fonts, block.style) : null;
|
|
785
|
+
const restShift = alignShift(style.textAlign, rtl);
|
|
786
|
+
const lastShift = alignShift(lastAlign, rtl);
|
|
787
|
+
// a justified line fills its room, whatever made it a piece at a time
|
|
788
|
+
const justify = justification(style);
|
|
789
|
+
const lineJustify: LineJustify | null =
|
|
790
|
+
fonts && (justify.rest || justify.last)
|
|
791
|
+
? {
|
|
792
|
+
fonts,
|
|
793
|
+
base,
|
|
794
|
+
options: {
|
|
795
|
+
lineHeight: lineHeightMul,
|
|
796
|
+
align: 'left',
|
|
797
|
+
direction: style.direction,
|
|
798
|
+
overflowWrap: wrapWords,
|
|
799
|
+
},
|
|
800
|
+
}
|
|
801
|
+
: null;
|
|
802
|
+
// The first line in its `::first-line` fonts, until a line is closed
|
|
803
|
+
const lineOne = options.firstLineStyle
|
|
804
|
+
? firstLineSetting(
|
|
805
|
+
block,
|
|
806
|
+
items,
|
|
807
|
+
options.firstLineStyle,
|
|
808
|
+
fonts,
|
|
809
|
+
strut,
|
|
810
|
+
lineJustify,
|
|
811
|
+
)
|
|
812
|
+
: null;
|
|
813
|
+
/** Close the open line; `last` where the text or a forced break ends it,
|
|
814
|
+
* which `text-align-last` aligns. */
|
|
815
|
+
const close = (last = false): void => {
|
|
816
|
+
const onFirst = lineOne !== null && !lines.length;
|
|
817
|
+
const line = finishLine(
|
|
818
|
+
open,
|
|
819
|
+
y,
|
|
820
|
+
// the room beside the floats over the whole line box, which an
|
|
821
|
+
// inline-block can make taller than its text (CSS 2.1 9.5)
|
|
822
|
+
(height) => bandAt(options, y, Math.max(height, guess)),
|
|
823
|
+
last || !wraps(style) ? lastShift : restShift,
|
|
824
|
+
rtl,
|
|
825
|
+
lines.length
|
|
826
|
+
? strut
|
|
827
|
+
: withRoom(onFirst ? lineOne.strut : strut, options.firstStrut),
|
|
828
|
+
onFirst ? lineOne.lifts : lifts,
|
|
829
|
+
(last || !wraps(style) ? justify.last : justify.rest)
|
|
830
|
+
? onFirst
|
|
831
|
+
? lineOne.justify
|
|
832
|
+
: lineJustify
|
|
833
|
+
: null,
|
|
834
|
+
);
|
|
194
835
|
if (!line) {
|
|
195
836
|
open = openLine(0);
|
|
837
|
+
placeDeferred();
|
|
196
838
|
return;
|
|
197
839
|
}
|
|
198
840
|
lines.push(line);
|
|
199
841
|
widest = Math.max(widest, line.width);
|
|
200
842
|
y += line.height;
|
|
201
843
|
open = openLine(0);
|
|
844
|
+
placeDeferred();
|
|
202
845
|
};
|
|
203
846
|
|
|
204
847
|
while (index < items.length) {
|
|
205
|
-
const band = bandAt(options, y,
|
|
848
|
+
const band = bandAt(options, y, guess);
|
|
206
849
|
const available = band.right - band.left;
|
|
207
850
|
const item = items[index];
|
|
851
|
+
const onFirst = lineOne !== null && !lines.length;
|
|
852
|
+
const lineItems = onFirst ? lineOne.items : items;
|
|
853
|
+
const lineBase = onFirst ? lineOne.base : base;
|
|
854
|
+
|
|
855
|
+
if (item.kind === 'float') {
|
|
856
|
+
// No higher than the top of the line it is met on (CSS 2.1 9.5.1):
|
|
857
|
+
// at that top where it fits beside what the line holds already, and
|
|
858
|
+
// what the line holds moves over for it; under the line where it
|
|
859
|
+
// does not, with the rest of the line's content still on the line.
|
|
860
|
+
// Where the line cannot break at the float, what follows it up to
|
|
861
|
+
// where it can is on the line too, and has to fit beside it as well,
|
|
862
|
+
// as browsers place one: `nowrap` text it is in the middle of runs on
|
|
863
|
+
// past it otherwise, under it.
|
|
864
|
+
const outer = options.floatBoxes!.size(item.box);
|
|
865
|
+
// and after a float that waits for the next line, since none goes
|
|
866
|
+
// higher than one before it (9.5.1, rule 5)
|
|
867
|
+
let fits =
|
|
868
|
+
!deferred.length &&
|
|
869
|
+
(isEmpty(open) || open.x + pendingWidth + outer <= available);
|
|
870
|
+
if (fits && !isEmpty(open) && !breaksAtEnd(open, style)) {
|
|
871
|
+
const after = unbreakableAfter(
|
|
872
|
+
lineItems,
|
|
873
|
+
index + 1,
|
|
874
|
+
style,
|
|
875
|
+
fonts!,
|
|
876
|
+
lineBase,
|
|
877
|
+
);
|
|
878
|
+
fits = open.x + pendingWidth + after + outer <= available;
|
|
879
|
+
}
|
|
880
|
+
if (fits) {
|
|
881
|
+
options.floatBoxes!.place(item.box, options.startY + y);
|
|
882
|
+
floatsPlaced.push(item.box);
|
|
883
|
+
const left = bandAt(options, y, guess).left;
|
|
884
|
+
if (!isEmpty(open) && left !== open.left) {
|
|
885
|
+
const dx = left - open.left;
|
|
886
|
+
for (const text of open.texts) text.drawX += dx;
|
|
887
|
+
for (const placed of open.atomics) placed.x += dx;
|
|
888
|
+
for (const edge of open.edges) edge.x += dx;
|
|
889
|
+
open.left = left;
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
deferred.push(item.box);
|
|
893
|
+
}
|
|
894
|
+
index += 1;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (item.kind === 'edge') {
|
|
899
|
+
if (item.side === 'start') {
|
|
900
|
+
pending.push(item);
|
|
901
|
+
pendingWidth += item.width;
|
|
902
|
+
} else {
|
|
903
|
+
// an end edge follows the content it closes onto its line; it may
|
|
904
|
+
// run past the line's end, as a trailing space does, rather than
|
|
905
|
+
// leave the content it closes
|
|
906
|
+
placePending(band.left);
|
|
907
|
+
const placed: EdgePlacement = {
|
|
908
|
+
box: item.box,
|
|
909
|
+
side: 'end',
|
|
910
|
+
x: band.left + open.x,
|
|
911
|
+
width: item.width,
|
|
912
|
+
};
|
|
913
|
+
open.edges.push(placed);
|
|
914
|
+
open.order.push({ kind: 'edge', at: open.x, item: placed });
|
|
915
|
+
open.x += item.width;
|
|
916
|
+
open.left = band.left;
|
|
917
|
+
}
|
|
918
|
+
index += 1;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
208
921
|
|
|
209
922
|
if (item.kind === 'atomic') {
|
|
210
923
|
const box = item.box;
|
|
211
924
|
const outer = box.width + box.marginLeft + box.marginRight;
|
|
212
|
-
|
|
925
|
+
// An inline-block makes its line as tall as itself, and the line box
|
|
926
|
+
// must not run into a float over that height either (CSS 2.1 9.5):
|
|
927
|
+
// the room is what the floats leave beside all of it.
|
|
928
|
+
const tall = box.marginTop + box.height + box.marginBottom;
|
|
929
|
+
const lineTall = Math.max(guess, tall);
|
|
930
|
+
const room = tall > guess ? bandAt(options, y, tall) : band;
|
|
931
|
+
const roomWidth = room.right - room.left;
|
|
932
|
+
// a line with nothing on it keeps what comes first, indent or not:
|
|
933
|
+
// there is no break before it (a first line's `text-indent` is room
|
|
934
|
+
// taken, and no content)
|
|
935
|
+
if (open.x !== open.indent && open.x + pendingWidth + outer > roomWidth) {
|
|
213
936
|
close();
|
|
214
937
|
continue;
|
|
215
938
|
}
|
|
216
|
-
|
|
939
|
+
// an inline-block wider than the room a float left goes below it,
|
|
940
|
+
// as a word does (below)
|
|
941
|
+
if (
|
|
942
|
+
isEmpty(open) &&
|
|
943
|
+
pendingWidth + outer > roomWidth &&
|
|
944
|
+
roomWidth < options.width
|
|
945
|
+
) {
|
|
946
|
+
const below = belowFloats(options, y, lineTall);
|
|
947
|
+
if (below !== null) {
|
|
948
|
+
y = below;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
placePending(room.left);
|
|
953
|
+
const placed: AtomicPlacement = {
|
|
954
|
+
box,
|
|
955
|
+
x: room.left + open.x + box.marginLeft,
|
|
956
|
+
y: 0,
|
|
957
|
+
};
|
|
958
|
+
const raise = atomicRaise(fonts, box, (box.parent ?? block).style);
|
|
959
|
+
if (raise) placed.raise = raise;
|
|
960
|
+
open.atomics.push(placed);
|
|
961
|
+
open.order.push({ kind: 'atomic', at: open.x, item: placed });
|
|
217
962
|
open.x += outer;
|
|
963
|
+
open.hang = 0;
|
|
964
|
+
open.left = room.left;
|
|
218
965
|
index += 1;
|
|
219
966
|
continue;
|
|
220
967
|
}
|
|
221
968
|
|
|
222
|
-
const
|
|
969
|
+
const plain = segmentFrom(lineItems, index, offset);
|
|
970
|
+
// measured as it will be drawn where it is justified, its spaces
|
|
971
|
+
// spaced apart (`HAIR`) — the same text, so every offset holds
|
|
972
|
+
const segment = lineJustify
|
|
973
|
+
? { ...plain, runs: spacedApart(plain.runs) }
|
|
974
|
+
: plain;
|
|
223
975
|
// `segmentFrom` always passes at least the text item it started on, so
|
|
224
976
|
// this advances even when the slice came out empty — which is what stops
|
|
225
977
|
// a zero-length tail from spinning the loop.
|
|
@@ -234,19 +986,45 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
234
986
|
// one layout, every line of it. This is the exit that keeps the loop
|
|
235
987
|
// below from being quadratic on an ordinary paragraph: it runs line by
|
|
236
988
|
// line only while it has to.
|
|
989
|
+
// An inline box's edges of no width on the open line are no room it
|
|
990
|
+
// takes, and are still on it: an empty `<span>` with a tall line height
|
|
991
|
+
// before the text was left on a line of its own after it, and the
|
|
992
|
+
// text's line was as short as the paragraph's
|
|
993
|
+
// (and its lines all aligned alike, which the last is not where
|
|
994
|
+
// `text-align-last` sets it apart)
|
|
237
995
|
const tailIsPlain =
|
|
996
|
+
!alignedApart &&
|
|
997
|
+
!onFirst &&
|
|
998
|
+
!strutted &&
|
|
238
999
|
segment.nextIndex >= items.length &&
|
|
239
1000
|
open.x === 0 &&
|
|
240
1001
|
!open.atomics.length &&
|
|
1002
|
+
!open.edges.length &&
|
|
1003
|
+
!pending.length &&
|
|
1004
|
+
!deferred.length &&
|
|
241
1005
|
!(options.floats?.intersects(options.startY + y, Infinity) ?? false);
|
|
242
1006
|
if (tailIsPlain) {
|
|
243
|
-
const
|
|
1007
|
+
const tailOptions = {
|
|
244
1008
|
maxWidth: wraps(style) ? Math.max(1, available) : undefined,
|
|
245
1009
|
lineHeight: lineHeightMul,
|
|
246
1010
|
align,
|
|
247
1011
|
direction: style.direction,
|
|
248
|
-
|
|
1012
|
+
overflowWrap: wrapWords,
|
|
1013
|
+
};
|
|
1014
|
+
let layout = fonts.layout(segment.runs, base, tailOptions);
|
|
249
1015
|
LAYOUT_RUNS.set(layout, segment.runs);
|
|
1016
|
+
// the lines past the floats are justified as the lines beside them
|
|
1017
|
+
// were (`finishLine`), in the room they have
|
|
1018
|
+
layout = justifiedLayout(
|
|
1019
|
+
fonts,
|
|
1020
|
+
segment.runs,
|
|
1021
|
+
base,
|
|
1022
|
+
tailOptions,
|
|
1023
|
+
layout,
|
|
1024
|
+
Math.max(1, available),
|
|
1025
|
+
justify,
|
|
1026
|
+
).layout;
|
|
1027
|
+
const tail: LineBox[] = [];
|
|
250
1028
|
for (let i = 0; i < layout.lines.length; i += 1) {
|
|
251
1029
|
const natural = layout.lines[i];
|
|
252
1030
|
const text: LineText = {
|
|
@@ -257,24 +1035,33 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
257
1035
|
textStart: segment.spans.documentAt(natural.start),
|
|
258
1036
|
textEnd: segment.spans.documentAt(natural.end),
|
|
259
1037
|
layoutStart: natural.start,
|
|
1038
|
+
spans: segment.spans,
|
|
260
1039
|
};
|
|
261
|
-
|
|
1040
|
+
segment.spans.giveGaps(text, natural.start, natural.end);
|
|
1041
|
+
tail.push({
|
|
262
1042
|
x: band.left + natural.x,
|
|
263
1043
|
y: y + natural.y,
|
|
264
1044
|
width: natural.width,
|
|
265
1045
|
height: natural.height,
|
|
266
|
-
baseline: natural.baseline,
|
|
1046
|
+
baseline: natural.baseline - natural.y,
|
|
267
1047
|
texts: [text],
|
|
268
1048
|
textStart: text.textStart,
|
|
269
1049
|
textEnd: text.textEnd,
|
|
270
1050
|
atomics: [],
|
|
271
1051
|
});
|
|
272
|
-
widest = Math.max(widest, natural.width);
|
|
273
1052
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1053
|
+
// or a line at a time, where one of them is shorter than the strut
|
|
1054
|
+
if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
|
|
1055
|
+
for (const line of tail) {
|
|
1056
|
+
lines.push(line);
|
|
1057
|
+
widest = Math.max(widest, line.width);
|
|
1058
|
+
}
|
|
1059
|
+
y += layout.height;
|
|
1060
|
+
index = segment.nextIndex;
|
|
1061
|
+
offset = 0;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
strutted = true;
|
|
278
1065
|
}
|
|
279
1066
|
|
|
280
1067
|
// One line: `maxLines: 1` cuts the layout at the first break and its
|
|
@@ -283,15 +1070,30 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
283
1070
|
// re-cut (which is what an earlier shape paid, per line, beside every
|
|
284
1071
|
// float). ntk's shaping memo makes the successive cuts cheap; only the
|
|
285
1072
|
// line breaker re-runs.
|
|
286
|
-
const room = Math.max(1, available - open.x);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1073
|
+
const room = Math.max(1, available - open.x - pendingWidth);
|
|
1074
|
+
// A float met inside a word, or inside a `nowrap` element, is no place
|
|
1075
|
+
// for the line to break: the text after it up to where it may break
|
|
1076
|
+
// is on this line too, so this line keeps room for it — or breaks
|
|
1077
|
+
// before the word it is tied to. It overran the line otherwise, the
|
|
1078
|
+
// float having gone below, and the break it could have taken passed.
|
|
1079
|
+
const tied =
|
|
1080
|
+
wraps(style) &&
|
|
1081
|
+
items[segment.nextIndex]?.kind === 'float' &&
|
|
1082
|
+
!BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
|
|
1083
|
+
? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
|
|
1084
|
+
: 0;
|
|
1085
|
+
const fragment = fonts.layout(segment.runs, lineBase, {
|
|
1086
|
+
maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
|
|
1087
|
+
lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
|
|
1088
|
+
// Never aligned by the text layout: the alignment belongs to the whole
|
|
1089
|
+
// line — its text, its atomics and its inline boxes' edges together —
|
|
1090
|
+
// which only this loop can see, and `finishLine` shifts it all. Laid
|
|
1091
|
+
// out aligned, the first fragment of a centred line was centred alone
|
|
1092
|
+
// and whatever followed it on the line was placed as though it had
|
|
1093
|
+
// not been, over it.
|
|
1094
|
+
align: 'left',
|
|
294
1095
|
direction: style.direction,
|
|
1096
|
+
overflowWrap: wrapWords,
|
|
295
1097
|
maxLines: 1,
|
|
296
1098
|
});
|
|
297
1099
|
LAYOUT_RUNS.set(fragment, segment.runs);
|
|
@@ -301,96 +1103,436 @@ export function layoutInline(block: Box, options: InlineOptions): InlineResult {
|
|
|
301
1103
|
offset = 0;
|
|
302
1104
|
continue;
|
|
303
1105
|
}
|
|
304
|
-
|
|
1106
|
+
// Nothing fits beside the floats — the first word is wider than the
|
|
1107
|
+
// room they leave, and was either run past it or cut inside itself to
|
|
1108
|
+
// fit — so the line moves down to where a float ends, and tries again
|
|
1109
|
+
// there (CSS 2.1 9.5). Only where a float took some of the line's width:
|
|
1110
|
+
// past the floats the line has the whole of it, and a word wider still
|
|
1111
|
+
// is `overflow-wrap`'s to break or to let run past.
|
|
1112
|
+
if (
|
|
1113
|
+
isEmpty(open) &&
|
|
1114
|
+
available < options.width &&
|
|
1115
|
+
tooNarrow(segment.runs, first.end, first.width, room)
|
|
1116
|
+
) {
|
|
1117
|
+
const below = belowFloats(options, y, guess);
|
|
1118
|
+
if (below !== null) {
|
|
1119
|
+
y = below;
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
// An inline box's opening edge goes with the first word after it: where
|
|
1124
|
+
// the word does not fit the room the edge leaves, both go to the next
|
|
1125
|
+
// line, rather than the edge being left at this one's end. And a word
|
|
1126
|
+
// that may start a line — after a space the line ends on, or after an
|
|
1127
|
+
// atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
|
|
1128
|
+
// next line whole where it does not fit what is left of this one,
|
|
1129
|
+
// rather than being broken inside itself to fit it: the text after an
|
|
1130
|
+
// inline-block, or after a float it was cut at, was its first letter
|
|
1131
|
+
// at the line's end and the rest on the next.
|
|
1132
|
+
const startsLine =
|
|
1133
|
+
wraps(style) &&
|
|
1134
|
+
(open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
|
|
1135
|
+
if (
|
|
1136
|
+
!isEmpty(open) &&
|
|
1137
|
+
(pendingWidth > 0
|
|
1138
|
+
? tooNarrow(segment.runs, first.end, first.width, room)
|
|
1139
|
+
: startsLine &&
|
|
1140
|
+
// run past the room — its white space is no part of the width —
|
|
1141
|
+
// or cut inside itself to fit it
|
|
1142
|
+
(first.width > room + 0.5 ||
|
|
1143
|
+
tooNarrow(segment.runs, first.end, first.width, room)))
|
|
1144
|
+
) {
|
|
1145
|
+
close();
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
placePending(band.left);
|
|
1149
|
+
const placed: LineText = {
|
|
305
1150
|
layout: fragment,
|
|
306
1151
|
layoutLine: 0,
|
|
307
|
-
//
|
|
308
|
-
// origin is the band edge plus the cursor — subtracting `first.x`
|
|
309
|
-
// here would left-align a centred line. Mid-line continuations were
|
|
310
|
-
// laid out `left`, where `first.x` is zero and the two agree.
|
|
1152
|
+
// laid out `left`, so the fragment starts at the cursor
|
|
311
1153
|
drawX: band.left + open.x,
|
|
312
1154
|
drawY: 0, // filled in by `finishLine`, which is where the top is known
|
|
313
1155
|
textStart: segment.spans.documentAt(first.start),
|
|
314
1156
|
textEnd: segment.spans.documentAt(first.end),
|
|
315
1157
|
layoutStart: first.start,
|
|
1158
|
+
spans: segment.spans,
|
|
1159
|
+
};
|
|
1160
|
+
segment.spans.giveGaps(placed, first.start, first.end);
|
|
1161
|
+
open.texts.push(placed);
|
|
1162
|
+
open.order.push({
|
|
1163
|
+
kind: 'text',
|
|
1164
|
+
at: open.x,
|
|
1165
|
+
item: placed,
|
|
1166
|
+
reads: readingOf(segment.runs, first.start, first.end),
|
|
316
1167
|
});
|
|
317
1168
|
open.x += first.width;
|
|
318
1169
|
open.left = band.left;
|
|
1170
|
+
open.hang = 0;
|
|
319
1171
|
|
|
320
1172
|
// Did the segment wrap? ntk answers with `truncated`. A layout that does
|
|
321
1173
|
// not carry the flag — react-x11's Cocoa engine reports none — is asked
|
|
322
1174
|
// the same of its line ends instead: it wrapped if anything but
|
|
323
1175
|
// whitespace follows the first line. Read as "fitted", a cut fragment
|
|
324
1176
|
// advanced past the whole segment, and a paragraph beside a float lost
|
|
325
|
-
// every line after its first.
|
|
326
|
-
|
|
1177
|
+
// every line after its first. And a line that ends at a `<br>` is over
|
|
1178
|
+
// whether or not anything followed it in this segment: what comes next
|
|
1179
|
+
// — an atomic, an element's edge — is the next line's.
|
|
1180
|
+
const wrapped =
|
|
1181
|
+
breakBefore(segment.runs, first.end) ||
|
|
1182
|
+
(fragment.truncated ?? inkBeyond(segment.runs, first.end));
|
|
327
1183
|
if (!wrapped) {
|
|
328
1184
|
// It fitted: the cursor stays on this line for whatever comes next.
|
|
329
1185
|
//
|
|
330
|
-
// With one correction first.
|
|
331
|
-
// right at a real line end, wrong here, where the "line"
|
|
332
|
-
// fragment and an atomic follows on the same one: `Name
|
|
333
|
-
// the space out to width zero and the control sat flush
|
|
334
|
-
//
|
|
335
|
-
//
|
|
1186
|
+
// With one correction first. A text engine strips a line's trailing
|
|
1187
|
+
// whitespace — right at a real line end, wrong here, where the "line"
|
|
1188
|
+
// is only a fragment and an atomic follows on the same one: `Name
|
|
1189
|
+
// <input>` laid the space out to width zero and the control sat flush
|
|
1190
|
+
// against the label. The stripped advance is measured back and added
|
|
1191
|
+
// to the cursor: what the engine strips, and no more (`hungSpaces`).
|
|
336
1192
|
if (segment.nextIndex < items.length) {
|
|
1193
|
+
const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
|
|
337
1194
|
let trailing = '';
|
|
338
1195
|
for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
|
|
339
1196
|
const text = segment.runs[i].text;
|
|
340
|
-
const m =
|
|
1197
|
+
const m = hung.exec(text);
|
|
341
1198
|
if (!m) break;
|
|
342
1199
|
trailing = m[0] + trailing;
|
|
343
1200
|
if (m[0].length < text.length) break;
|
|
344
1201
|
}
|
|
345
1202
|
if (trailing) {
|
|
346
|
-
|
|
1203
|
+
// and noted: if what follows goes to the next line after all,
|
|
1204
|
+
// these spaces end this one, where CSS removes them (16.6.1)
|
|
1205
|
+
open.hang =
|
|
347
1206
|
spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
|
|
348
1207
|
trailing.length;
|
|
1208
|
+
open.x += open.hang;
|
|
349
1209
|
}
|
|
350
1210
|
}
|
|
351
1211
|
index = segment.nextIndex;
|
|
352
1212
|
offset = 0;
|
|
353
1213
|
continue;
|
|
354
1214
|
}
|
|
355
|
-
close();
|
|
1215
|
+
close(breakBefore(segment.runs, first.end));
|
|
356
1216
|
const advanced = advance(items, index, offset, first.end);
|
|
357
1217
|
index = advanced.index;
|
|
358
1218
|
offset = advanced.offset;
|
|
359
1219
|
}
|
|
360
1220
|
|
|
361
|
-
if (
|
|
1221
|
+
if (pending.length) placePending(bandAt(options, y, guess).left);
|
|
1222
|
+
if (open.texts.length || open.atomics.length || open.edges.length) {
|
|
1223
|
+
close(true);
|
|
1224
|
+
}
|
|
1225
|
+
placeDeferred();
|
|
362
1226
|
|
|
363
|
-
return { lines, height: y, width: widest };
|
|
1227
|
+
if (!floatsPlaced.length) return { lines, height: y, width: widest };
|
|
1228
|
+
const floatRow = besideLines(
|
|
1229
|
+
Number.isFinite(options.width) ? [] : lines,
|
|
1230
|
+
floatsPlaced,
|
|
1231
|
+
options.startY,
|
|
1232
|
+
);
|
|
1233
|
+
return { lines, height: y, width: widest, floatRow };
|
|
364
1234
|
}
|
|
365
1235
|
|
|
366
1236
|
const EMPTY: InlineResult = { lines: [], height: 0, width: 0 };
|
|
367
1237
|
|
|
368
|
-
/**
|
|
1238
|
+
/**
|
|
1239
|
+
* Which line each float a paragraph placed stands beside: the one whose top
|
|
1240
|
+
* it went at, whose content moved over for it — added up on the line, for
|
|
1241
|
+
* what the line is measured with (`LineBox.floats`). The widest row of the
|
|
1242
|
+
* rest, side by side where they went at one top, is the answer. Both lines
|
|
1243
|
+
* and floats come in order down the paragraph, since a float goes no
|
|
1244
|
+
* higher than the one before it.
|
|
1245
|
+
*
|
|
1246
|
+
* The lines are handed over only where the paragraph had no width limit,
|
|
1247
|
+
* where its content is measured at its widest and a float is beside a line
|
|
1248
|
+
* because nothing made the line go under it. At a width, the least a
|
|
1249
|
+
* paragraph can be is its widest word or float, not the two side by side
|
|
1250
|
+
* (where the word ran past the room a float left it), and its floats are
|
|
1251
|
+
* rows of their own.
|
|
1252
|
+
*/
|
|
1253
|
+
function besideLines(lines: LineBox[], placed: Box[], startY: number): number {
|
|
1254
|
+
let row = 0;
|
|
1255
|
+
let rowTop = NaN;
|
|
1256
|
+
let widest = 0;
|
|
1257
|
+
let at = 0;
|
|
1258
|
+
for (const box of placed) {
|
|
1259
|
+
const top = box.y - box.marginTop - startY;
|
|
1260
|
+
const width = box.width + box.marginLeft + box.marginRight;
|
|
1261
|
+
while (at < lines.length && lines[at].y < top - 0.01) at += 1;
|
|
1262
|
+
const line = lines[at];
|
|
1263
|
+
if (line && Math.abs(line.y - top) <= 0.01) {
|
|
1264
|
+
line.floats = (line.floats ?? 0) + width;
|
|
1265
|
+
} else if (Math.abs(top - rowTop) <= 0.01) row += width;
|
|
1266
|
+
else {
|
|
1267
|
+
widest = Math.max(widest, row);
|
|
1268
|
+
row = width;
|
|
1269
|
+
rowTop = top;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return Math.max(widest, row);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** A no-break space in a paragraph's face, and its advance, per font
|
|
1276
|
+
* manager and per style — which the cascade shares between every element
|
|
1277
|
+
* of a kind, so a document of paragraphs asks once. */
|
|
1278
|
+
const SPACERS = new WeakMap<
|
|
1279
|
+
object,
|
|
1280
|
+
WeakMap<ComputedStyle, { run: TextRun; advance: number }>
|
|
1281
|
+
>();
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* An inline box's edge as a run of the one layout: a no-break space in the
|
|
1285
|
+
* paragraph's face, letter-spaced to the edge's width. A no-break space
|
|
1286
|
+
* glues to the text on either side, so a start edge goes to whichever line
|
|
1287
|
+
* the box's first word goes to, and an end edge stays with its last; a line
|
|
1288
|
+
* may still break after a space before it (UAX #14, LB12a). It is drawn as
|
|
1289
|
+
* nothing, and is no part of the document's text: the line's text notes
|
|
1290
|
+
* where it is (`LineText.gaps`). Laid out piece by piece instead, a
|
|
1291
|
+
* paragraph with an inline `<code>` in it — whose padding is an edge —
|
|
1292
|
+
* cost five layouts where one does, and a document of them reflowed two
|
|
1293
|
+
* and a half times slower.
|
|
1294
|
+
*/
|
|
1295
|
+
function spacerRun(
|
|
1296
|
+
fonts: FontsLike,
|
|
1297
|
+
style: ComputedStyle,
|
|
1298
|
+
width: number,
|
|
1299
|
+
): TextRun {
|
|
1300
|
+
let byStyle = SPACERS.get(fonts);
|
|
1301
|
+
if (!byStyle) {
|
|
1302
|
+
byStyle = new WeakMap();
|
|
1303
|
+
SPACERS.set(fonts, byStyle);
|
|
1304
|
+
}
|
|
1305
|
+
let spacer = byStyle.get(style);
|
|
1306
|
+
if (!spacer) {
|
|
1307
|
+
const face = {
|
|
1308
|
+
family: style.fontFamily,
|
|
1309
|
+
size: style.fontSize,
|
|
1310
|
+
weight: style.fontWeight,
|
|
1311
|
+
style:
|
|
1312
|
+
style.fontStyle === 'normal'
|
|
1313
|
+
? ('normal' as const)
|
|
1314
|
+
: ('italic' as const),
|
|
1315
|
+
};
|
|
1316
|
+
const run: TextRun = { text: '\u00a0', ...face };
|
|
1317
|
+
spacer = { run, advance: fonts.layout([run], face, {}).width };
|
|
1318
|
+
byStyle.set(style, spacer);
|
|
1319
|
+
}
|
|
1320
|
+
return { ...spacer.run, letterSpacing: width - spacer.advance };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** Text that bidi could reorder around a spacer: any right-to-left
|
|
1324
|
+
* letter, and the explicit embedding and isolate controls. */
|
|
1325
|
+
const REORDERS =
|
|
1326
|
+
/[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc\u202a-\u202e\u2066-\u2069]|[\ud802\ud803\ud83a\ud83b]/;
|
|
1327
|
+
|
|
1328
|
+
/** Whether a paragraph's edges can be spacers: it reads left to right, and
|
|
1329
|
+
* nothing in it can be reordered. */
|
|
1330
|
+
function spacersHold(style: ComputedStyle, items: Item[]): boolean {
|
|
1331
|
+
if (style.direction !== 'ltr') return false;
|
|
1332
|
+
for (const item of items) {
|
|
1333
|
+
if (item.kind === 'text' && REORDERS.test(item.run.text)) return false;
|
|
1334
|
+
// A negative margin takes room back. As a spacer it is a space with
|
|
1335
|
+
// negative letter spacing, which ntk's line fill counts and CoreText's
|
|
1336
|
+
// typesetter breaks the line before: the line at a time does the sum.
|
|
1337
|
+
if (
|
|
1338
|
+
item.kind === 'edge' &&
|
|
1339
|
+
(item.box.style.direction !== 'ltr' || item.width < 0)
|
|
1340
|
+
)
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* A paragraph whose inline boxes have edges, as one layout with the edges
|
|
1348
|
+
* in it as spacers (`spacerRun`), each line told where its spacers are and
|
|
1349
|
+
* where they put the edges. Null where the engine began a line with an
|
|
1350
|
+
* edge that closes a box: a break after a box's last character is after
|
|
1351
|
+
* its end edge, which stays on the line of the content it closes (CSS Text
|
|
1352
|
+
* 3, 5.1), and a line break after a space may come before a no-break space
|
|
1353
|
+
* (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
|
|
1354
|
+
* keep it there.
|
|
1355
|
+
*/
|
|
1356
|
+
function layoutSpaced(
|
|
1357
|
+
items: Item[],
|
|
1358
|
+
base: Record<string, unknown>,
|
|
1359
|
+
style: ComputedStyle,
|
|
1360
|
+
width: number,
|
|
1361
|
+
lineHeightMul: number,
|
|
1362
|
+
align: string,
|
|
1363
|
+
fonts: FontsLike,
|
|
1364
|
+
): InlineResult | null {
|
|
1365
|
+
const wrapWords = overflowWrapOf(style, items);
|
|
1366
|
+
const runs: TextRun[] = [];
|
|
1367
|
+
const spans = new SpanMap();
|
|
1368
|
+
const spacers: { at: number; edge: Extract<Item, { kind: 'edge' }> }[] = [];
|
|
1369
|
+
let doc = 0;
|
|
1370
|
+
for (const item of items) {
|
|
1371
|
+
if (item.kind === 'text') {
|
|
1372
|
+
doc = item.start;
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
for (const item of items) {
|
|
1377
|
+
if (item.kind === 'edge') {
|
|
1378
|
+
spacers.push({ at: spans.laidOut, edge: item });
|
|
1379
|
+
spans.add(doc, 1, null);
|
|
1380
|
+
runs.push(spacerRun(fonts, style, item.width));
|
|
1381
|
+
} else if (item.kind === 'text') {
|
|
1382
|
+
spans.add(
|
|
1383
|
+
item.start,
|
|
1384
|
+
item.run.text.length,
|
|
1385
|
+
item.control ? null : item.box,
|
|
1386
|
+
);
|
|
1387
|
+
runs.push(item.run);
|
|
1388
|
+
if (!item.control) doc = item.start + item.run.text.length;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const layoutOptions = {
|
|
1392
|
+
maxWidth: wraps(style) ? width : undefined,
|
|
1393
|
+
lineHeight: lineHeightMul,
|
|
1394
|
+
align,
|
|
1395
|
+
direction: style.direction,
|
|
1396
|
+
overflowWrap: wrapWords,
|
|
1397
|
+
};
|
|
1398
|
+
let layout = fonts.layout(runs, base, layoutOptions);
|
|
1399
|
+
LAYOUT_RUNS.set(layout, runs);
|
|
1400
|
+
// justified with its spacers in it, which are edges and not spaces
|
|
1401
|
+
layout = justifiedLayout(
|
|
1402
|
+
fonts,
|
|
1403
|
+
runs,
|
|
1404
|
+
base,
|
|
1405
|
+
layoutOptions,
|
|
1406
|
+
layout,
|
|
1407
|
+
width,
|
|
1408
|
+
justification(style),
|
|
1409
|
+
new Set(spacers.map((spacer) => spacer.at)),
|
|
1410
|
+
).layout;
|
|
1411
|
+
const place = wraps(style) ? null : unwrappedPlacer(style, width);
|
|
1412
|
+
let offsets: number[] | null = null;
|
|
1413
|
+
const lines: LineBox[] = [];
|
|
1414
|
+
let widest = 0;
|
|
1415
|
+
let next = 0;
|
|
1416
|
+
for (let i = 0; i < layout.lines.length; i += 1) {
|
|
1417
|
+
const natural = layout.lines[i];
|
|
1418
|
+
const dx = place ? place(natural) : 0;
|
|
1419
|
+
const edges: EdgePlacement[] = [];
|
|
1420
|
+
while (next < spacers.length && spacers[next].at < natural.end) {
|
|
1421
|
+
const { at, edge } = spacers[next];
|
|
1422
|
+
next += 1;
|
|
1423
|
+
if (at < natural.start) continue;
|
|
1424
|
+
if (i > 0 && edge.side === 'end' && at === natural.start) return null;
|
|
1425
|
+
const run = natural.runs?.find((r) => r.start === at);
|
|
1426
|
+
const x = run
|
|
1427
|
+
? natural.x + run.x
|
|
1428
|
+
: layout.caretPosition(
|
|
1429
|
+
codePointAt((offsets ??= layoutOffsets(layout)), at),
|
|
1430
|
+
).x;
|
|
1431
|
+
edges.push({
|
|
1432
|
+
box: edge.box,
|
|
1433
|
+
side: edge.side,
|
|
1434
|
+
x: x + dx,
|
|
1435
|
+
width: edge.width,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
const text: LineText = {
|
|
1439
|
+
layout,
|
|
1440
|
+
layoutLine: i,
|
|
1441
|
+
drawX: dx,
|
|
1442
|
+
drawY: 0,
|
|
1443
|
+
textStart: spans.documentAt(natural.start),
|
|
1444
|
+
textEnd: spans.documentAt(natural.end),
|
|
1445
|
+
layoutStart: natural.start,
|
|
1446
|
+
spans,
|
|
1447
|
+
};
|
|
1448
|
+
spans.giveGaps(text, natural.start, natural.end);
|
|
1449
|
+
lines.push({
|
|
1450
|
+
x: natural.x + dx,
|
|
1451
|
+
y: natural.y,
|
|
1452
|
+
width: natural.width,
|
|
1453
|
+
height: natural.height,
|
|
1454
|
+
baseline: natural.baseline - natural.y,
|
|
1455
|
+
texts: [text],
|
|
1456
|
+
textStart: text.textStart,
|
|
1457
|
+
textEnd: text.textEnd,
|
|
1458
|
+
atomics: [],
|
|
1459
|
+
...(edges.length ? { edges } : null),
|
|
1460
|
+
});
|
|
1461
|
+
widest = Math.max(widest, natural.width);
|
|
1462
|
+
}
|
|
1463
|
+
return { lines, height: layout.height, width: widest };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** The code point a code-unit offset into a layout's text starts. */
|
|
1467
|
+
function codePointAt(offsets: number[], units: number): number {
|
|
1468
|
+
if (!offsets.length) return units;
|
|
1469
|
+
let lo = 0;
|
|
1470
|
+
let hi = offsets.length - 1;
|
|
1471
|
+
while (lo < hi) {
|
|
1472
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1473
|
+
if (offsets[mid] <= units) lo = mid;
|
|
1474
|
+
else hi = mid - 1;
|
|
1475
|
+
}
|
|
1476
|
+
return lo;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* A code-unit offset in a line's layout as a document index, and back:
|
|
1481
|
+
* `textStart` is `layoutStart`, and every spacer before the offset is a
|
|
1482
|
+
* unit of the layout that is no text of the document's. A document offset
|
|
1483
|
+
* with a spacer right at it is past the spacer, where a caret or a range's
|
|
1484
|
+
* start goes — inside a padded box, after its padding — and before it as
|
|
1485
|
+
* a range's `end`, so a selection does not take in the padding after it.
|
|
1486
|
+
*/
|
|
1487
|
+
export function documentOffsetOf(text: LineText, units: number): number {
|
|
1488
|
+
let doc = text.textStart + (units - text.layoutStart);
|
|
1489
|
+
for (const gap of text.gaps ?? []) if (gap < units) doc -= 1;
|
|
1490
|
+
return doc;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
export function layoutOffsetOf(
|
|
1494
|
+
text: LineText,
|
|
1495
|
+
doc: number,
|
|
1496
|
+
end = false,
|
|
1497
|
+
): number {
|
|
1498
|
+
let units = text.layoutStart + (doc - text.textStart);
|
|
1499
|
+
for (const gap of text.gaps ?? []) {
|
|
1500
|
+
if (end ? gap < units : gap <= units) units += 1;
|
|
1501
|
+
}
|
|
1502
|
+
return units;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Append one layout's lines as LineBoxes at an offset, each moved where
|
|
1506
|
+
* `place` says; returns the widest. */
|
|
369
1507
|
function emitLayout(
|
|
370
1508
|
layout: TextLayoutLike,
|
|
371
1509
|
spans: SpanMap,
|
|
372
1510
|
xOff: number,
|
|
373
1511
|
yOff: number,
|
|
374
1512
|
lines: LineBox[],
|
|
1513
|
+
place: LinePlacer | null = null,
|
|
375
1514
|
): number {
|
|
376
1515
|
let widest = 0;
|
|
377
1516
|
for (let i = 0; i < layout.lines.length; i += 1) {
|
|
378
1517
|
const natural = layout.lines[i];
|
|
1518
|
+
const dx = place ? place(natural) : 0;
|
|
379
1519
|
const text: LineText = {
|
|
380
1520
|
layout,
|
|
381
1521
|
layoutLine: i,
|
|
382
|
-
drawX: xOff,
|
|
1522
|
+
drawX: xOff + dx,
|
|
383
1523
|
drawY: yOff,
|
|
384
1524
|
textStart: spans.documentAt(natural.start),
|
|
385
1525
|
textEnd: spans.documentAt(natural.end),
|
|
386
1526
|
layoutStart: natural.start,
|
|
1527
|
+
spans,
|
|
387
1528
|
};
|
|
1529
|
+
spans.giveGaps(text, natural.start, natural.end);
|
|
388
1530
|
lines.push({
|
|
389
|
-
x: xOff + natural.x,
|
|
1531
|
+
x: xOff + dx + natural.x,
|
|
390
1532
|
y: yOff + natural.y,
|
|
391
1533
|
width: natural.width,
|
|
392
1534
|
height: natural.height,
|
|
393
|
-
baseline: natural.baseline,
|
|
1535
|
+
baseline: natural.baseline - natural.y,
|
|
394
1536
|
texts: [text],
|
|
395
1537
|
textStart: text.textStart,
|
|
396
1538
|
textEnd: text.textEnd,
|
|
@@ -410,6 +1552,20 @@ const CHUNK_TRIGGER_CHARS = 16384;
|
|
|
410
1552
|
const CHUNK_MAX_CHARS = 8192;
|
|
411
1553
|
const CHUNK_MAX_HARD_LINES = 64;
|
|
412
1554
|
|
|
1555
|
+
/** Where `layoutChunked` cuts a text — a chunk's most characters and hard
|
|
1556
|
+
* lines — and the cut each chunk's layout makes, if any. */
|
|
1557
|
+
interface Chunking {
|
|
1558
|
+
chars: number;
|
|
1559
|
+
hardLines: number;
|
|
1560
|
+
cut: Cut | null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const CHUNKS: Chunking = {
|
|
1564
|
+
chars: CHUNK_MAX_CHARS,
|
|
1565
|
+
hardLines: CHUNK_MAX_HARD_LINES,
|
|
1566
|
+
cut: null,
|
|
1567
|
+
};
|
|
1568
|
+
|
|
413
1569
|
/**
|
|
414
1570
|
* A long hard-broken text — a `<pre>` holding a log, a wall of `<br>`s — laid
|
|
415
1571
|
* out as a sequence of layouts split at newline boundaries.
|
|
@@ -431,7 +1587,9 @@ function layoutChunked(
|
|
|
431
1587
|
lineHeightMul: number,
|
|
432
1588
|
align: string,
|
|
433
1589
|
fonts: FontsLike,
|
|
1590
|
+
chunking: Chunking = CHUNKS,
|
|
434
1591
|
): InlineResult {
|
|
1592
|
+
const wrapWords = overflowWrapOf(style, items);
|
|
435
1593
|
const lines: LineBox[] = [];
|
|
436
1594
|
let widest = 0;
|
|
437
1595
|
let y = 0;
|
|
@@ -441,16 +1599,35 @@ function layoutChunked(
|
|
|
441
1599
|
let chars = 0;
|
|
442
1600
|
let hardLines = 0;
|
|
443
1601
|
|
|
1602
|
+
const { cut } = chunking;
|
|
1603
|
+
const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
|
|
444
1604
|
const flush = (): void => {
|
|
445
1605
|
if (!chunkRuns.length) return;
|
|
446
|
-
const
|
|
447
|
-
maxWidth: wraps(style) ? width : undefined,
|
|
1606
|
+
const chunkOptions = {
|
|
1607
|
+
maxWidth: wraps(style) || cut ? width : undefined,
|
|
448
1608
|
lineHeight: lineHeightMul,
|
|
449
1609
|
align,
|
|
450
1610
|
direction: style.direction,
|
|
451
|
-
|
|
1611
|
+
overflowWrap: cutWrap(style, cut, wrapWords),
|
|
1612
|
+
...cut,
|
|
1613
|
+
};
|
|
1614
|
+
let layout = fonts.layout(chunkRuns, base, chunkOptions);
|
|
452
1615
|
LAYOUT_RUNS.set(layout, chunkRuns);
|
|
453
|
-
|
|
1616
|
+
// a chunk ends at a forced break or at the paragraph's end, which is
|
|
1617
|
+
// where `justifiedRuns` takes a chunk's last line to end
|
|
1618
|
+
layout = justifiedLayout(
|
|
1619
|
+
fonts,
|
|
1620
|
+
chunkRuns,
|
|
1621
|
+
base,
|
|
1622
|
+
chunkOptions,
|
|
1623
|
+
layout,
|
|
1624
|
+
width,
|
|
1625
|
+
justification(style),
|
|
1626
|
+
).layout;
|
|
1627
|
+
widest = Math.max(
|
|
1628
|
+
widest,
|
|
1629
|
+
emitLayout(layout, chunkSpans, 0, y, lines, place),
|
|
1630
|
+
);
|
|
454
1631
|
y += layout.height;
|
|
455
1632
|
chunkRuns = [];
|
|
456
1633
|
chunkSpans = new SpanMap();
|
|
@@ -460,7 +1637,7 @@ function layoutChunked(
|
|
|
460
1637
|
|
|
461
1638
|
for (const item of items) {
|
|
462
1639
|
let text = item.run.text;
|
|
463
|
-
let docStart = item.
|
|
1640
|
+
let docStart = item.start;
|
|
464
1641
|
while (text.length) {
|
|
465
1642
|
// Scan up to the budgets, remembering the last newline: the split has
|
|
466
1643
|
// to land just after one for the chunking to be exact.
|
|
@@ -470,8 +1647,8 @@ function layoutChunked(
|
|
|
470
1647
|
let taken = chars;
|
|
471
1648
|
while (
|
|
472
1649
|
i < text.length &&
|
|
473
|
-
taken <
|
|
474
|
-
lines2 <
|
|
1650
|
+
taken < chunking.chars &&
|
|
1651
|
+
lines2 < chunking.hardLines
|
|
475
1652
|
) {
|
|
476
1653
|
if (text.charCodeAt(i) === 10) {
|
|
477
1654
|
lines2 += 1;
|
|
@@ -485,7 +1662,7 @@ function layoutChunked(
|
|
|
485
1662
|
chunkRuns.push(
|
|
486
1663
|
text === item.run.text ? item.run : { ...item.run, text },
|
|
487
1664
|
);
|
|
488
|
-
chunkSpans.add(docStart, text.length);
|
|
1665
|
+
chunkSpans.add(docStart, text.length, item.box);
|
|
489
1666
|
chars = taken;
|
|
490
1667
|
hardLines = lines2;
|
|
491
1668
|
break;
|
|
@@ -501,7 +1678,7 @@ function layoutChunked(
|
|
|
501
1678
|
const next = text.indexOf('\n', i);
|
|
502
1679
|
const end = next < 0 ? text.length : next + 1;
|
|
503
1680
|
chunkRuns.push({ ...item.run, text: text.slice(0, end) });
|
|
504
|
-
chunkSpans.add(docStart, end);
|
|
1681
|
+
chunkSpans.add(docStart, end, item.box);
|
|
505
1682
|
flush();
|
|
506
1683
|
docStart += end;
|
|
507
1684
|
text = text.slice(end);
|
|
@@ -509,7 +1686,7 @@ function layoutChunked(
|
|
|
509
1686
|
}
|
|
510
1687
|
const cut = lastBreak + 1;
|
|
511
1688
|
chunkRuns.push({ ...item.run, text: text.slice(0, cut) });
|
|
512
|
-
chunkSpans.add(docStart, cut);
|
|
1689
|
+
chunkSpans.add(docStart, cut, item.box);
|
|
513
1690
|
flush();
|
|
514
1691
|
docStart += cut;
|
|
515
1692
|
text = text.slice(cut);
|
|
@@ -527,100 +1704,868 @@ interface OpenLine {
|
|
|
527
1704
|
left: number;
|
|
528
1705
|
texts: LineText[];
|
|
529
1706
|
atomics: AtomicPlacement[];
|
|
1707
|
+
edges: EdgePlacement[];
|
|
1708
|
+
/** All of the above in the order they were placed, which is the logical
|
|
1709
|
+
* order, and where on the line each began: what a line with
|
|
1710
|
+
* right-to-left text on it is put into visual order by. */
|
|
1711
|
+
order: Placed[];
|
|
1712
|
+
/** The advance of the spaces the line ends with, kept in `x` for what
|
|
1713
|
+
* may follow them on it and no part of the line if nothing does. */
|
|
1714
|
+
hang: number;
|
|
1715
|
+
/** The first line's `text-indent`, which `x` starts at. */
|
|
1716
|
+
indent: number;
|
|
530
1717
|
}
|
|
531
1718
|
|
|
1719
|
+
type Placed =
|
|
1720
|
+
| { kind: 'text'; at: number; item: LineText; reads: Reading }
|
|
1721
|
+
| { kind: 'atomic'; at: number; item: AtomicPlacement }
|
|
1722
|
+
| { kind: 'edge'; at: number; item: EdgePlacement };
|
|
1723
|
+
|
|
1724
|
+
/** Which way a fragment's letters read: all one way, both, or neither —
|
|
1725
|
+
* spaces, digits and punctuation, which are neutrals. */
|
|
1726
|
+
type Reading = 'ltr' | 'rtl' | 'mixed' | null;
|
|
1727
|
+
|
|
532
1728
|
function openLine(indent: number): OpenLine {
|
|
533
|
-
return {
|
|
1729
|
+
return {
|
|
1730
|
+
x: indent,
|
|
1731
|
+
left: 0,
|
|
1732
|
+
texts: [],
|
|
1733
|
+
atomics: [],
|
|
1734
|
+
edges: [],
|
|
1735
|
+
order: [],
|
|
1736
|
+
hang: 0,
|
|
1737
|
+
indent,
|
|
1738
|
+
};
|
|
534
1739
|
}
|
|
535
1740
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
descent = Math.max(descent, natural.height - natural.baseline);
|
|
545
|
-
height = Math.max(height, natural.height);
|
|
546
|
-
}
|
|
547
|
-
for (const placed of open.atomics) {
|
|
548
|
-
const h =
|
|
549
|
-
placed.box.height + placed.box.marginTop + placed.box.marginBottom;
|
|
550
|
-
const va = placed.box.style.verticalAlign;
|
|
551
|
-
if (va === 'top' || va === 'bottom' || va === 'middle')
|
|
552
|
-
height = Math.max(height, h);
|
|
553
|
-
else ascent = Math.max(ascent, h);
|
|
554
|
-
}
|
|
555
|
-
height = Math.max(height, ascent + descent);
|
|
556
|
-
const baseline = Math.max(ascent, (height - ascent - descent) / 2 + ascent);
|
|
1741
|
+
/** Whether a line may break where it has got to: after the white space it
|
|
1742
|
+
* ends on, which hangs, or after an inline-block, where its block wraps. */
|
|
1743
|
+
function breaksAtEnd(open: OpenLine, style: ComputedStyle): boolean {
|
|
1744
|
+
return (
|
|
1745
|
+
wraps(style) &&
|
|
1746
|
+
(open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic')
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
557
1749
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1750
|
+
/**
|
|
1751
|
+
* How wide the content from `from` on is up to where its line may break:
|
|
1752
|
+
* its text up to a space it may break at — one `holdNoWrap` left a space —
|
|
1753
|
+
* or, in a block that does not wrap, up to a line's end, with the edges of
|
|
1754
|
+
* inline boxes on the way, and an inline-block, which has a break before it
|
|
1755
|
+
* where the block wraps. Floats take no room on the line.
|
|
1756
|
+
*/
|
|
1757
|
+
function unbreakableAfter(
|
|
1758
|
+
items: readonly Item[],
|
|
1759
|
+
from: number,
|
|
1760
|
+
style: ComputedStyle,
|
|
1761
|
+
fonts: FontsLike,
|
|
1762
|
+
base: Record<string, unknown>,
|
|
1763
|
+
): number {
|
|
1764
|
+
const wrapping = wraps(style);
|
|
1765
|
+
const runs: TextRun[] = [];
|
|
1766
|
+
let width = 0;
|
|
1767
|
+
for (let i = from; i < items.length; i += 1) {
|
|
1768
|
+
const item = items[i];
|
|
1769
|
+
if (item.kind === 'float') continue;
|
|
1770
|
+
if (item.kind === 'edge') {
|
|
1771
|
+
width += item.width;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (item.kind === 'atomic') {
|
|
1775
|
+
if (wrapping) break;
|
|
1776
|
+
width += item.box.width + item.box.marginLeft + item.box.marginRight;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const text = item.run.text;
|
|
1780
|
+
const stop = wrapping ? text.search(/[ \t\n]/) : text.indexOf('\n');
|
|
1781
|
+
if (stop < 0) {
|
|
1782
|
+
runs.push(item.run);
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (stop > 0) runs.push({ ...item.run, text: text.slice(0, stop) });
|
|
1786
|
+
break;
|
|
1787
|
+
}
|
|
1788
|
+
return runs.length ? width + fonts.layout(runs, base, {}).width : width;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Close a line: its height and baseline from what is on it, then where it
|
|
1793
|
+
* sits — in the room `bandFor` answers for a line that tall, and aligned in
|
|
1794
|
+
* it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
|
|
1795
|
+
* centred), moved onto every text, atomic and edge on it at once.
|
|
1796
|
+
*/
|
|
1797
|
+
function finishLine(
|
|
1798
|
+
open: OpenLine,
|
|
1799
|
+
y: number,
|
|
1800
|
+
bandFor: (height: number) => { left: number; right: number },
|
|
1801
|
+
shift: number,
|
|
1802
|
+
rtl: boolean,
|
|
1803
|
+
strut: InlineDecoration | null,
|
|
1804
|
+
lifts: Lifts | null = null,
|
|
1805
|
+
justify: LineJustify | null = null,
|
|
1806
|
+
): LineBox | null {
|
|
1807
|
+
if (!open.texts.length && !open.atomics.length && !open.edges.length) {
|
|
1808
|
+
return null;
|
|
1809
|
+
}
|
|
1810
|
+
const reordered = open.order.some((p) => needsOrdering(p, rtl));
|
|
1811
|
+
if (reordered) reorderLine(open, rtl);
|
|
1812
|
+
// Every line box starts with the block's strut, its face at its line
|
|
1813
|
+
// height, so a line of images alone is still as tall as `line-height`
|
|
1814
|
+
// makes a line (CSS 2.1 10.8.1). Not one with nothing on it that takes
|
|
1815
|
+
// room, which has no height at all (9.4.2), nor one that holds a block
|
|
1816
|
+
// an inline box was split around, which is no line of text.
|
|
1817
|
+
const held =
|
|
1818
|
+
strut &&
|
|
1819
|
+
(open.texts.length > 0 ||
|
|
1820
|
+
open.atomics.some((placed) =>
|
|
1821
|
+
placed.box.style.display.startsWith('inline'),
|
|
1822
|
+
) ||
|
|
1823
|
+
open.edges.some(
|
|
1824
|
+
(edge) => edge.width !== 0 || hasEdgeOn(edge.box, edge.side),
|
|
1825
|
+
));
|
|
1826
|
+
let ascent = held ? strut.ascent : 0;
|
|
1827
|
+
let descent = held ? strut.descent : 0;
|
|
1828
|
+
// A text `vertical-align` raises takes its own box's room about where it
|
|
1829
|
+
// is raised to. A `top` or `bottom` box takes the room of all it holds
|
|
1830
|
+
// on the line about its baseline, and the line is as tall as that.
|
|
1831
|
+
const lifted = lifts ? open.texts.map((text) => lifts.of(text)) : null;
|
|
1832
|
+
let edges: Map<
|
|
1833
|
+
Box,
|
|
1834
|
+
{ to: 'top' | 'bottom'; ascent: number; descent: number }
|
|
1835
|
+
> | null = null;
|
|
1836
|
+
for (let i = 0; i < open.texts.length; i += 1) {
|
|
1837
|
+
const lift = lifted?.[i];
|
|
1838
|
+
if (lift) {
|
|
1839
|
+
if (lift.edge) {
|
|
1840
|
+
edges ??= new Map();
|
|
1841
|
+
let room = edges.get(lift.edge.box);
|
|
1842
|
+
if (!room) {
|
|
1843
|
+
room = {
|
|
1844
|
+
to: lift.edge.to,
|
|
1845
|
+
ascent: lift.edge.ascent,
|
|
1846
|
+
descent: lift.edge.descent,
|
|
1847
|
+
};
|
|
1848
|
+
edges.set(lift.edge.box, room);
|
|
1849
|
+
}
|
|
1850
|
+
room.ascent = Math.max(room.ascent, lift.ascent + lift.raise);
|
|
1851
|
+
room.descent = Math.max(room.descent, lift.descent - lift.raise);
|
|
1852
|
+
} else {
|
|
1853
|
+
ascent = Math.max(ascent, lift.ascent + lift.raise);
|
|
1854
|
+
descent = Math.max(descent, lift.descent - lift.raise);
|
|
1855
|
+
}
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
|
|
1859
|
+
const own = natural.baseline - natural.y;
|
|
1860
|
+
ascent = Math.max(ascent, own);
|
|
1861
|
+
descent = Math.max(descent, natural.height - own);
|
|
1862
|
+
}
|
|
1863
|
+
// an inline box with no text on the line is on it all the same, as tall
|
|
1864
|
+
// as its own face and line height make it (CSS 2.1 10.8): an empty one,
|
|
1865
|
+
// or one whose text is on another line — raised, or with a line height
|
|
1866
|
+
// of its own, which an empty `<span>` made the line's no taller for.
|
|
1867
|
+
// Only on a line that is one at all, which is none for boxes of no
|
|
1868
|
+
// edges and no text (9.4.2)
|
|
1869
|
+
if (lifts && held) {
|
|
1870
|
+
for (const placed of open.edges) {
|
|
1871
|
+
if (placed.side !== 'start' || placed.box.kind !== 'inline') continue;
|
|
1872
|
+
const room = lifts.ofBox(placed.box);
|
|
1873
|
+
if (!room) continue;
|
|
1874
|
+
ascent = Math.max(ascent, room.ascent + room.raise);
|
|
1875
|
+
descent = Math.max(descent, room.descent - room.raise);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
// `top` and `bottom` boxes are set against the line's edges rather than
|
|
1879
|
+
// its baseline, so all they ask of it is to be as tall as they are
|
|
1880
|
+
let top = 0;
|
|
1881
|
+
let bottom = 0;
|
|
1882
|
+
for (const placed of open.atomics) {
|
|
1883
|
+
const box = placed.box;
|
|
1884
|
+
const h = box.height + box.marginTop + box.marginBottom;
|
|
1885
|
+
const va = box.style.verticalAlign;
|
|
1886
|
+
if (va === 'top' || va === 'bottom') {
|
|
1887
|
+
if (va === 'top') top = Math.max(top, h);
|
|
1888
|
+
else bottom = Math.max(bottom, h);
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
// on the line's baseline by its own, raised from it by its
|
|
1892
|
+
// `vertical-align`, and below it by the rest of it
|
|
1893
|
+
const raise = placed.raise ?? 0;
|
|
1894
|
+
const b = atomicBaseline(box);
|
|
1895
|
+
ascent = Math.max(ascent, b + raise);
|
|
1896
|
+
descent = Math.max(descent, h - b - raise);
|
|
1897
|
+
}
|
|
1898
|
+
if (edges) {
|
|
1899
|
+
for (const room of edges.values()) {
|
|
1900
|
+
const h = room.ascent + room.descent;
|
|
1901
|
+
if (room.to === 'top') top = Math.max(top, h);
|
|
1902
|
+
else bottom = Math.max(bottom, h);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
// Where the baseline goes in a line one of them made taller than the
|
|
1906
|
+
// rest is left open (CSS 2.1 10.8.1). Browsers keep it where the rest
|
|
1907
|
+
// puts it, under the line's top, and only a taller `bottom` box moves it
|
|
1908
|
+
// down: an image set `top` beside a line of text has the text at its
|
|
1909
|
+
// top, not halfway down it.
|
|
1910
|
+
let height = ascent + descent;
|
|
1911
|
+
let baseline = ascent;
|
|
1912
|
+
if (bottom > height) {
|
|
1913
|
+
baseline += bottom - height;
|
|
1914
|
+
height = bottom;
|
|
1915
|
+
}
|
|
1916
|
+
height = Math.max(height, top);
|
|
1917
|
+
|
|
1918
|
+
// Placed beside the floats at the text's height; a float the whole line
|
|
1919
|
+
// reaches moves it, and the alignment divides the room that is left.
|
|
1920
|
+
// The indent is at the line's start, which is its right in a
|
|
1921
|
+
// right-to-left paragraph: the content moves back over it there, and
|
|
1922
|
+
// the room it takes is at the other end (CSS 2.1 16.1).
|
|
1923
|
+
const band = bandFor(height);
|
|
1924
|
+
const indent = rtl ? open.indent : 0;
|
|
1925
|
+
let used = open.x - open.hang - indent;
|
|
1926
|
+
let free = band.right - indent - band.left - used;
|
|
1927
|
+
// A justified line fills its room (CSS Text 3, 7.4). Not yet one that
|
|
1928
|
+
// reads right to left, whose pieces are in visual order by now and would
|
|
1929
|
+
// take their shares from the wrong end: that is left at its start, as it
|
|
1930
|
+
// was.
|
|
1931
|
+
if (justify && !rtl && !reordered && Number.isFinite(free) && free > 0) {
|
|
1932
|
+
const grew = justifyLine(open, free, justify);
|
|
1933
|
+
used += grew;
|
|
1934
|
+
free -= grew;
|
|
1935
|
+
}
|
|
1936
|
+
let dx = band.left - open.left - indent;
|
|
1937
|
+
if (shift > 0 && Number.isFinite(free) && free > 0) dx += free * shift;
|
|
1938
|
+
if (dx) {
|
|
1939
|
+
for (const text of open.texts) text.drawX += dx;
|
|
1940
|
+
for (const placed of open.atomics) placed.x += dx;
|
|
1941
|
+
for (const edge of open.edges) edge.x += dx;
|
|
1942
|
+
open.left += dx;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
let textStart = Infinity;
|
|
1946
|
+
let textEnd = 0;
|
|
1947
|
+
for (const text of open.texts) {
|
|
1948
|
+
textStart = Math.min(textStart, text.textStart);
|
|
1949
|
+
textEnd = Math.max(textEnd, text.textEnd);
|
|
1950
|
+
}
|
|
564
1951
|
const line: LineBox = {
|
|
565
|
-
x: open.left,
|
|
1952
|
+
x: open.left + indent,
|
|
566
1953
|
y,
|
|
567
|
-
width:
|
|
1954
|
+
width: used,
|
|
568
1955
|
height,
|
|
569
1956
|
baseline,
|
|
570
1957
|
texts: open.texts,
|
|
571
1958
|
textStart: Number.isFinite(textStart) ? textStart : 0,
|
|
572
1959
|
textEnd,
|
|
573
1960
|
atomics: open.atomics,
|
|
1961
|
+
...(open.edges.length ? { edges: open.edges } : null),
|
|
574
1962
|
};
|
|
575
|
-
for (const placed of open.atomics)
|
|
576
|
-
placed.y = y + alignAtomic(placed.box, line);
|
|
1963
|
+
for (const placed of open.atomics) placed.y = y + alignAtomic(placed, line);
|
|
577
1964
|
// Every fragment on this line shares the line's baseline, whatever its own
|
|
578
|
-
// layout thinks: that is what makes a small `<
|
|
579
|
-
// the same baseline rather than on its own.
|
|
580
|
-
|
|
1965
|
+
// layout thinks: that is what makes a small `<span>` beside body text sit
|
|
1966
|
+
// on the same baseline rather than on its own. One that `vertical-align`
|
|
1967
|
+
// raises is drawn that far above it.
|
|
1968
|
+
for (let i = 0; i < open.texts.length; i += 1) {
|
|
1969
|
+
const text = open.texts[i];
|
|
581
1970
|
const natural = text.layout.lines[text.layoutLine];
|
|
582
|
-
|
|
1971
|
+
let at = y + baseline;
|
|
1972
|
+
const lift = lifted?.[i];
|
|
1973
|
+
if (lift) {
|
|
1974
|
+
const room = lift.edge && edges!.get(lift.edge.box)!;
|
|
1975
|
+
if (lift.edge?.to === 'top') at = y + room!.ascent - lift.raise;
|
|
1976
|
+
else if (lift.edge) at = y + height - room!.descent - lift.raise;
|
|
1977
|
+
else at -= lift.raise;
|
|
1978
|
+
if (at !== y + baseline) {
|
|
1979
|
+
TEXT_RAISES.set(text, y + baseline - at);
|
|
1980
|
+
SHIFTED_LINES.add(line);
|
|
1981
|
+
}
|
|
1982
|
+
// and the boxes it is in, up to the `top` or `bottom` one, whose
|
|
1983
|
+
// backgrounds are drawn from their own baselines
|
|
1984
|
+
if (lift.edge && lifts) {
|
|
1985
|
+
const owner = text.spans.boxAt?.(text.layoutStart);
|
|
1986
|
+
for (
|
|
1987
|
+
let at2 = owner?.parent;
|
|
1988
|
+
at2?.kind === 'inline';
|
|
1989
|
+
at2 = at2.parent
|
|
1990
|
+
) {
|
|
1991
|
+
const placed = lifts.edgeOf(at2);
|
|
1992
|
+
if (!placed) break;
|
|
1993
|
+
const room = edges!.get(placed.edge);
|
|
1994
|
+
if (!room) break;
|
|
1995
|
+
const own =
|
|
1996
|
+
placed.edge.style.verticalAlign === 'top'
|
|
1997
|
+
? y + room.ascent
|
|
1998
|
+
: y + height - room.descent;
|
|
1999
|
+
let raises = LINE_BOX_RAISES.get(line);
|
|
2000
|
+
if (!raises) LINE_BOX_RAISES.set(line, (raises = new Map()));
|
|
2001
|
+
raises.set(at2, y + baseline - (own - placed.raise));
|
|
2002
|
+
SHIFTED_LINES.add(line);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
text.drawY = at - natural.baseline;
|
|
583
2007
|
}
|
|
584
2008
|
return line;
|
|
585
2009
|
}
|
|
586
2010
|
|
|
2011
|
+
/**
|
|
2012
|
+
* Put a line's pieces in visual order. The text and the atomics first, by
|
|
2013
|
+
* UAX #9's L2 over pieces rather than characters: a fragment is in order
|
|
2014
|
+
* inside itself already — the engine laid it out bidirectionally — so what
|
|
2015
|
+
* is left to order is the fragments and the atomics between them, each at
|
|
2016
|
+
* a level — a fragment the direction its letters read in, or the
|
|
2017
|
+
* paragraph's where they read both ways, and a neutral (an atomic, a
|
|
2018
|
+
* fragment of spaces and digits) its neighbours' where they agree and the
|
|
2019
|
+
* paragraph's where they do not (N1, N2).
|
|
2020
|
+
*
|
|
2021
|
+
* Then each element's edges, innermost first, around what of it is on the
|
|
2022
|
+
* line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
|
|
2023
|
+
* on its leftmost box and a rtl one's right ones on its rightmost, which is
|
|
2024
|
+
* a matter of its `direction` rather than of which way its text reads — so
|
|
2025
|
+
* an edge is placed beside its element's pieces, never ordered among them.
|
|
2026
|
+
* Each piece keeps the room it took, and a right-to-left fragment's hung
|
|
2027
|
+
* trailing space goes to its left, where its logical end is.
|
|
2028
|
+
*/
|
|
2029
|
+
function reorderLine(open: OpenLine, rtl: boolean): void {
|
|
2030
|
+
const order = open.order;
|
|
2031
|
+
const n = order.length;
|
|
2032
|
+
if (!n) return;
|
|
2033
|
+
const content: number[] = [];
|
|
2034
|
+
for (let i = 0; i < n; i += 1) if (order[i].kind !== 'edge') content.push(i);
|
|
2035
|
+
const strong = content.map((i): boolean | null => {
|
|
2036
|
+
const p = order[i];
|
|
2037
|
+
if (p.kind !== 'text' || p.reads === null) return null;
|
|
2038
|
+
return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
|
|
2039
|
+
});
|
|
2040
|
+
const strongAt = (from: number, step: number): boolean => {
|
|
2041
|
+
for (let k = from + step; k >= 0 && k < content.length; k += step) {
|
|
2042
|
+
if (strong[k] !== null) return strong[k]!;
|
|
2043
|
+
}
|
|
2044
|
+
return rtl;
|
|
2045
|
+
};
|
|
2046
|
+
const isRtl = strong.map((r, k) => {
|
|
2047
|
+
if (r !== null) return r;
|
|
2048
|
+
const before = strongAt(k, -1);
|
|
2049
|
+
return before === strongAt(k, 1) ? before : rtl;
|
|
2050
|
+
});
|
|
2051
|
+
// R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
|
|
2052
|
+
// in a right-to-left one
|
|
2053
|
+
const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
|
|
2054
|
+
const visual = content.map((_, k) => k);
|
|
2055
|
+
for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
|
|
2056
|
+
for (let a = 0; a < visual.length;) {
|
|
2057
|
+
if (levels[visual[a]] < level) {
|
|
2058
|
+
a += 1;
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
let b = a;
|
|
2062
|
+
while (b < visual.length && levels[visual[b]] >= level) b += 1;
|
|
2063
|
+
for (let i = a, j = b - 1; i < j; i += 1, j -= 1) {
|
|
2064
|
+
[visual[i], visual[j]] = [visual[j], visual[i]];
|
|
2065
|
+
}
|
|
2066
|
+
a = b;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
const seq = visual.map((k) => content[k]);
|
|
2070
|
+
|
|
2071
|
+
const elements = new Map<Box, { start: number | null; end: number | null }>();
|
|
2072
|
+
for (let i = 0; i < n; i += 1) {
|
|
2073
|
+
const p = order[i];
|
|
2074
|
+
if (p.kind !== 'edge') continue;
|
|
2075
|
+
const sides = elements.get(p.item.box) ?? { start: null, end: null };
|
|
2076
|
+
sides[p.item.side] = i;
|
|
2077
|
+
elements.set(p.item.box, sides);
|
|
2078
|
+
}
|
|
2079
|
+
const inside = [...elements].sort(([a], [b]) => depthOf(b) - depthOf(a));
|
|
2080
|
+
for (const [box, { start, end }] of inside) {
|
|
2081
|
+
// what of the element is on this line: everything between its edges,
|
|
2082
|
+
// or the line's end where one of them is on another line
|
|
2083
|
+
const from = start ?? -1;
|
|
2084
|
+
const to = end ?? n;
|
|
2085
|
+
let lo = -1;
|
|
2086
|
+
let hi = -1;
|
|
2087
|
+
for (let k = 0; k < seq.length; k += 1) {
|
|
2088
|
+
if (seq[k] > from && seq[k] < to) {
|
|
2089
|
+
if (lo < 0) lo = k;
|
|
2090
|
+
hi = k;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
if (lo < 0) {
|
|
2094
|
+
// Nothing of it here but its edges: they go where it is in the text,
|
|
2095
|
+
// after what comes before it, in the paragraph's direction.
|
|
2096
|
+
let at = rtl ? seq.length : 0;
|
|
2097
|
+
for (let i = from - 1; i >= 0; i -= 1) {
|
|
2098
|
+
const k = seq.indexOf(i);
|
|
2099
|
+
if (k >= 0) {
|
|
2100
|
+
at = rtl ? k : k + 1;
|
|
2101
|
+
break;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
lo = at;
|
|
2105
|
+
hi = at - 1;
|
|
2106
|
+
}
|
|
2107
|
+
const ltr = box.style.direction !== 'rtl';
|
|
2108
|
+
const left = ltr ? start : end;
|
|
2109
|
+
const right = ltr ? end : start;
|
|
2110
|
+
if (right !== null) seq.splice(hi + 1, 0, right);
|
|
2111
|
+
if (left !== null) seq.splice(lo, 0, left);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
let x = order[0].at;
|
|
2115
|
+
for (const i of seq) {
|
|
2116
|
+
const piece = order[i];
|
|
2117
|
+
const room = (i + 1 < n ? order[i + 1].at : open.x) - piece.at;
|
|
2118
|
+
let dx = x - piece.at;
|
|
2119
|
+
if (piece.kind === 'text') {
|
|
2120
|
+
const natural = piece.item.layout.lines[piece.item.layoutLine];
|
|
2121
|
+
const k = content.indexOf(i);
|
|
2122
|
+
if (isRtl[k] && natural) dx += Math.max(0, room - natural.width);
|
|
2123
|
+
piece.item.drawX += dx;
|
|
2124
|
+
} else {
|
|
2125
|
+
piece.item.x += dx;
|
|
2126
|
+
}
|
|
2127
|
+
x += room;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** Whether a piece can be out of place in logical order: anything on a
|
|
2132
|
+
* right-to-left line, a fragment that reads right to left, or the edge of
|
|
2133
|
+
* an element whose direction is not the line's. A line of left-to-right
|
|
2134
|
+
* text, the common one, is placed as it was built. */
|
|
2135
|
+
function needsOrdering(piece: Placed, rtl: boolean): boolean {
|
|
2136
|
+
if (rtl) return true;
|
|
2137
|
+
if (piece.kind === 'text') return piece.reads === 'rtl';
|
|
2138
|
+
return piece.kind === 'edge' && piece.item.box.style.direction === 'rtl';
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** How deep a box is in the tree — an element inside another is deeper. */
|
|
2142
|
+
export function depthOf(box: Box): number {
|
|
2143
|
+
let depth = 0;
|
|
2144
|
+
for (let at = box.parent; at; at = at.parent) depth += 1;
|
|
2145
|
+
return depth;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* Which way the text from `start` to `end` of a segment's runs reads, from
|
|
2150
|
+
* its letters. Asked of the text rather than of the engine's runs: a run of
|
|
2151
|
+
* the space before a word reads the paragraph's way wherever it starts a
|
|
2152
|
+
* fragment, which says nothing about the word, and an engine may not say
|
|
2153
|
+
* which way its runs went at all.
|
|
2154
|
+
*/
|
|
2155
|
+
function readingOf(runs: TextRun[], start: number, end: number): Reading {
|
|
2156
|
+
let right = false;
|
|
2157
|
+
let left = false;
|
|
2158
|
+
let at = 0;
|
|
2159
|
+
for (const run of runs) {
|
|
2160
|
+
const length = run.text.length;
|
|
2161
|
+
const from = Math.max(start, at);
|
|
2162
|
+
const to = Math.min(end, at + length);
|
|
2163
|
+
if (to > from) {
|
|
2164
|
+
const text =
|
|
2165
|
+
to - from === length ? run.text : run.text.slice(from - at, to - at);
|
|
2166
|
+
right ||= RIGHT_TO_LEFT.test(text);
|
|
2167
|
+
left ||= LEFT_TO_RIGHT.test(text);
|
|
2168
|
+
}
|
|
2169
|
+
at += length;
|
|
2170
|
+
if (at >= end) break;
|
|
2171
|
+
}
|
|
2172
|
+
return right ? (left ? 'mixed' : 'rtl') : left ? 'ltr' : null;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/** A letter of a script written right to left — Hebrew, Arabic, Syriac,
|
|
2176
|
+
* Thaana, N'Ko and their neighbours, their presentation forms, and the
|
|
2177
|
+
* supplementary planes' right-to-left blocks — and any other letter. */
|
|
2178
|
+
const RIGHT_TO_LEFT =
|
|
2179
|
+
/[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
|
|
2180
|
+
const LEFT_TO_RIGHT =
|
|
2181
|
+
/(?![\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}])\p{L}/u;
|
|
2182
|
+
|
|
587
2183
|
/** Where an atomic's top edge sits, relative to the line box top. */
|
|
588
|
-
function alignAtomic(
|
|
589
|
-
const
|
|
2184
|
+
function alignAtomic(placed: AtomicPlacement, line: LineBox): number {
|
|
2185
|
+
const box = placed.box;
|
|
590
2186
|
switch (box.style.verticalAlign) {
|
|
591
2187
|
case 'top':
|
|
592
2188
|
return 0;
|
|
593
2189
|
case 'bottom':
|
|
594
|
-
return line.height -
|
|
595
|
-
|
|
596
|
-
return
|
|
2190
|
+
return line.height - (box.height + box.marginTop + box.marginBottom);
|
|
2191
|
+
default:
|
|
2192
|
+
return line.baseline - atomicBaseline(box) - (placed.raise ?? 0);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* How far an atomic's `vertical-align` raises its baseline above its
|
|
2198
|
+
* parent's (CSS 2.1 10.8.1), as `Lifts` raises an inline box's: `sub` and
|
|
2199
|
+
* `super` by a fifth and a third of the parent's font size and a pixel,
|
|
2200
|
+
* `text-top` and `text-bottom` to the edges of the parent's font, `middle`
|
|
2201
|
+
* by its middle to half the parent's x-height above the baseline, a length
|
|
2202
|
+
* by itself and a percentage of its own line height. `top` and `bottom` are
|
|
2203
|
+
* the line box's edges, and raise nothing from a baseline.
|
|
2204
|
+
*
|
|
2205
|
+
* `middle` is the parent's baseline, not the middle of the line: an image
|
|
2206
|
+
* set `middle` beside text in a line an image before it made tall sat
|
|
2207
|
+
* where that image left room, and drew the text beside it a pixel off.
|
|
2208
|
+
* Read before the line moves the atomic, as `atomicBaseline` is.
|
|
2209
|
+
*/
|
|
2210
|
+
function atomicRaise(
|
|
2211
|
+
fonts: FontsLike,
|
|
2212
|
+
box: Box,
|
|
2213
|
+
parent: ComputedStyle,
|
|
2214
|
+
): number {
|
|
2215
|
+
const va = box.style.verticalAlign;
|
|
2216
|
+
switch (va) {
|
|
2217
|
+
case 'baseline':
|
|
2218
|
+
case 'top':
|
|
2219
|
+
case 'bottom':
|
|
2220
|
+
return 0;
|
|
597
2221
|
case 'sub':
|
|
598
|
-
return
|
|
2222
|
+
return -(parent.fontSize / 5 + parent.fontSize / 16);
|
|
599
2223
|
case 'super':
|
|
600
|
-
return
|
|
2224
|
+
return parent.fontSize / 3 + parent.fontSize / 16;
|
|
2225
|
+
}
|
|
2226
|
+
const h = box.height + box.marginTop + box.marginBottom;
|
|
2227
|
+
const b = atomicBaseline(box);
|
|
2228
|
+
switch (va) {
|
|
2229
|
+
case 'text-top':
|
|
2230
|
+
return faceExtent(fonts, parent).ascent - b;
|
|
2231
|
+
case 'text-bottom':
|
|
2232
|
+
return h - b - faceExtent(fonts, parent).descent;
|
|
2233
|
+
case 'middle':
|
|
2234
|
+
return xHeightOf(fonts, parent) / 2 + h / 2 - b;
|
|
601
2235
|
default:
|
|
602
|
-
|
|
603
|
-
|
|
2236
|
+
return typeof va === 'number'
|
|
2237
|
+
? va
|
|
2238
|
+
: resolve(va, lineHeightOf(fonts, box.style));
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Where an atomic's baseline is, from the top of its margin box (CSS 2.1
|
|
2244
|
+
* 10.8.1, 17.5.3): an inline-block's is its last line box's, an
|
|
2245
|
+
* inline-table's its first row's — its first line box's — and the bottom of
|
|
2246
|
+
* its margin box where it has none, is replaced, or clips what overflows
|
|
2247
|
+
* it. Set bottom-on-baseline, an inline-block's text sat its descent above
|
|
2248
|
+
* the text beside it, and a line holding one grew by as much.
|
|
2249
|
+
*
|
|
2250
|
+
* Read while the atomic is still where it was laid out, before the line
|
|
2251
|
+
* moves it: its lines are then at the same offset from its top as they will
|
|
2252
|
+
* be after.
|
|
2253
|
+
*/
|
|
2254
|
+
function atomicBaseline(box: Box): number {
|
|
2255
|
+
const bottom = box.height + box.marginTop + box.marginBottom;
|
|
2256
|
+
if (
|
|
2257
|
+
box.kind === 'replaced' ||
|
|
2258
|
+
scrolls(box.style) ||
|
|
2259
|
+
// layout containment keeps its baseline in (CSS Containment 2, 3.3)
|
|
2260
|
+
box.style.contain & CONTAIN_LAYOUT
|
|
2261
|
+
) {
|
|
2262
|
+
return bottom;
|
|
2263
|
+
}
|
|
2264
|
+
const baseline =
|
|
2265
|
+
box.kind === 'table'
|
|
2266
|
+
? tableBaseline(box)
|
|
2267
|
+
: // an inline flex box sits on its first (CSS Flexbox 8.5), where an
|
|
2268
|
+
// inline block sits on its last line's
|
|
2269
|
+
box.kind === 'flex'
|
|
2270
|
+
? firstBaselineIn(box)
|
|
2271
|
+
: lastBaselineIn(box);
|
|
2272
|
+
return baseline === null ? bottom : box.marginTop + (baseline - box.y);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* A flex container's first baseline, or its last (CSS Flexbox 8.5): that
|
|
2277
|
+
* of the first item on its first line that is aligned by its baseline — the
|
|
2278
|
+
* last on its last line — or where none is, of its first item, or last,
|
|
2279
|
+
* in `order`. An item gives its own, or where it has none, its border
|
|
2280
|
+
* box's bottom edge. Not its last child's: an `inline-flex` of a small
|
|
2281
|
+
* item and a big one sat on the big one's baseline. Null for a box with no
|
|
2282
|
+
* item.
|
|
2283
|
+
*/
|
|
2284
|
+
function flexBaseline(box: Box, first: boolean): number | null {
|
|
2285
|
+
let items: Box[] = [];
|
|
2286
|
+
let reordered = false;
|
|
2287
|
+
for (const child of box.children) {
|
|
2288
|
+
if (child.outOfFlow || child.isFloat) continue;
|
|
2289
|
+
if (child.kind === 'text' || child.kind === 'break') continue;
|
|
2290
|
+
if (child.style.order !== 0) reordered = true;
|
|
2291
|
+
items.push(child);
|
|
2292
|
+
}
|
|
2293
|
+
if (!items.length) return null;
|
|
2294
|
+
if (reordered) items.sort((a, b) => a.style.order - b.style.order);
|
|
2295
|
+
if (!first) items = items.reverse();
|
|
2296
|
+
const style = box.style;
|
|
2297
|
+
if (style.flexDirection.startsWith('row') && !style.grid) {
|
|
2298
|
+
// a line ends where the next item does not go on along the main axis,
|
|
2299
|
+
// which from the last item is where it goes on
|
|
2300
|
+
const back =
|
|
2301
|
+
((style.flexDirection === 'row-reverse') !==
|
|
2302
|
+
(style.direction === 'rtl')) !==
|
|
2303
|
+
!first;
|
|
2304
|
+
let last = NaN;
|
|
2305
|
+
for (const item of items) {
|
|
2306
|
+
if (!Number.isNaN(last) && (back ? item.x >= last : item.x <= last)) {
|
|
2307
|
+
break;
|
|
2308
|
+
}
|
|
2309
|
+
last = item.x;
|
|
2310
|
+
const align =
|
|
2311
|
+
item.style.alignSelf === 'auto'
|
|
2312
|
+
? style.alignItems
|
|
2313
|
+
: item.style.alignSelf;
|
|
2314
|
+
if (align === 'baseline') return itemBaselineOf(item, first, style);
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
return itemBaselineOf(items[0], first, style);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** A flex item's baseline, in document coordinates: its first line's or
|
|
2321
|
+
* its last's — its bottom margin edge where it clips what it holds — or
|
|
2322
|
+
* where it has none, its border box's bottom edge, which a grid does not
|
|
2323
|
+
* take from its item, and has none of its own then (CSS Grid 1, 9). */
|
|
2324
|
+
function itemBaselineOf(
|
|
2325
|
+
item: Box,
|
|
2326
|
+
first: boolean,
|
|
2327
|
+
container: ComputedStyle,
|
|
2328
|
+
): number | null {
|
|
2329
|
+
const found = childBaseline(item, first ? firstBaselineIn : lastBaselineIn);
|
|
2330
|
+
if (found !== null) return found;
|
|
2331
|
+
return container.grid ? null : item.y + item.height;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* A table's baseline: its first row's (CSS 2.1 17.5.3), which is the first
|
|
2336
|
+
* line of a cell in the row, or where no cell in it has a line, the bottom
|
|
2337
|
+
* content edge of the row's lowest cell. Not the table's bottom: a table
|
|
2338
|
+
* of empty rows stands on its first one, and the rest hang below the line.
|
|
2339
|
+
*/
|
|
2340
|
+
function tableBaseline(table: Box): number | null {
|
|
2341
|
+
const { cells } = tableGrid(table);
|
|
2342
|
+
let lowest = -Infinity;
|
|
2343
|
+
for (const cell of cells) {
|
|
2344
|
+
if (cell.row > 0) break;
|
|
2345
|
+
const found = firstBaselineIn(cell.box);
|
|
2346
|
+
if (found !== null) return found;
|
|
2347
|
+
const b = cell.box;
|
|
2348
|
+
lowest = Math.max(lowest, b.y + b.height - b.padBottom - b.borderBottom);
|
|
2349
|
+
}
|
|
2350
|
+
return lowest === -Infinity ? null : lowest;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* The baseline a box's content gives it, in document coordinates: its last
|
|
2355
|
+
* line box's, or its last in-flow child's with one — and a child that clips
|
|
2356
|
+
* what overflows it gives its bottom margin edge and no deeper, whatever
|
|
2357
|
+
* text it holds (CSS Box Alignment's synthesized baseline). Null where
|
|
2358
|
+
* nothing in it has one.
|
|
2359
|
+
*/
|
|
2360
|
+
function lastBaselineIn(box: Box): number | null {
|
|
2361
|
+
// a flex box's is its items', in the order it lays them out
|
|
2362
|
+
if (box.kind === 'flex') return flexBaseline(box, false);
|
|
2363
|
+
if (box.lines?.length) {
|
|
2364
|
+
const line = box.lines[box.lines.length - 1];
|
|
2365
|
+
return line.y + line.baseline;
|
|
2366
|
+
}
|
|
2367
|
+
for (let i = box.children.length - 1; i >= 0; i -= 1) {
|
|
2368
|
+
const found = childBaseline(box.children[i], lastBaselineIn);
|
|
2369
|
+
if (found !== null) return found;
|
|
2370
|
+
}
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/** The same, from the first line box or child: a table's baseline is its
|
|
2375
|
+
* first row's, and so is a table cell's. */
|
|
2376
|
+
export function firstBaselineIn(box: Box): number | null {
|
|
2377
|
+
if (box.kind === 'flex') return flexBaseline(box, true);
|
|
2378
|
+
if (box.lines?.length) return box.lines[0].y + box.lines[0].baseline;
|
|
2379
|
+
for (const child of box.children) {
|
|
2380
|
+
const found = childBaseline(child, firstBaselineIn);
|
|
2381
|
+
if (found !== null) return found;
|
|
2382
|
+
}
|
|
2383
|
+
return null;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function childBaseline(
|
|
2387
|
+
child: Box,
|
|
2388
|
+
inside: (box: Box) => number | null,
|
|
2389
|
+
): number | null {
|
|
2390
|
+
if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
|
|
2391
|
+
return null;
|
|
2392
|
+
}
|
|
2393
|
+
if (scrolls(child.style)) {
|
|
2394
|
+
return child.y + child.height + child.marginBottom;
|
|
2395
|
+
}
|
|
2396
|
+
return inside(child);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/** An absolutely positioned box among a paragraph's content, before the
|
|
2400
|
+
* item at `index`. */
|
|
2401
|
+
interface StaticMark {
|
|
2402
|
+
index: number;
|
|
2403
|
+
box: Box;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* The static position of each absolutely positioned box among a
|
|
2408
|
+
* paragraph's content (CSS 2.1 10.3.7, 10.6.4): where it would have been
|
|
2409
|
+
* in flow, found from the content before it. A block-level one would have
|
|
2410
|
+
* broken the line that content is on, so it goes under that line at the
|
|
2411
|
+
* line's start; an inline-level one goes on it, where the pen stood after
|
|
2412
|
+
* that content, or under it where a forced break ends the text; either goes
|
|
2413
|
+
* at the top where nothing comes before it. Taken as the block's content
|
|
2414
|
+
* top for every one of them, a box after a line of text was drawn over that
|
|
2415
|
+
* line, and a menu under its link came up on it. Not from a probe of
|
|
2416
|
+
* intrinsic width, whose room is no place for one.
|
|
2417
|
+
*/
|
|
2418
|
+
function staticPositions(
|
|
2419
|
+
statics: StaticMark[],
|
|
2420
|
+
items: Item[],
|
|
2421
|
+
lines: LineBox[],
|
|
2422
|
+
block: Box,
|
|
2423
|
+
options: InlineOptions,
|
|
2424
|
+
): void {
|
|
2425
|
+
if (!Number.isFinite(options.width)) return;
|
|
2426
|
+
for (const { index, box } of statics) {
|
|
2427
|
+
const inline = INLINE_BEFORE_ABSOLUTE.has(box.style);
|
|
2428
|
+
let top = lines.length ? lines[0].y : 0;
|
|
2429
|
+
let x = 0;
|
|
2430
|
+
const after = lines.length ? penAfter(items, index, lines) : null;
|
|
2431
|
+
if (after) {
|
|
2432
|
+
const { line, pen, broken } = after;
|
|
2433
|
+
if (inline && !broken) {
|
|
2434
|
+
top = line.y;
|
|
2435
|
+
x = pen;
|
|
2436
|
+
} else {
|
|
2437
|
+
top = line.y + line.height;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
box.staticPosition = {
|
|
2441
|
+
from: block,
|
|
2442
|
+
x: options.originX + x - block.x,
|
|
2443
|
+
right: options.originX + options.width - block.x,
|
|
2444
|
+
y: options.startY + top - block.y,
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/** The line the content before `items[index]` ended on, where the pen
|
|
2450
|
+
* stood after it, and whether it ended in a forced break. Null where no
|
|
2451
|
+
* text or atomic comes before. */
|
|
2452
|
+
function penAfter(
|
|
2453
|
+
items: Item[],
|
|
2454
|
+
index: number,
|
|
2455
|
+
lines: LineBox[],
|
|
2456
|
+
): { line: LineBox; pen: number; broken: boolean } | null {
|
|
2457
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
2458
|
+
const item = items[i];
|
|
2459
|
+
if (item.kind === 'edge') {
|
|
2460
|
+
// an inline box's margin, border or padding is content, which keeps
|
|
2461
|
+
// its line from being one of no height (CSS 2.1 9.4.2), even where
|
|
2462
|
+
// they add up to no width
|
|
2463
|
+
if (!hasEdgeOn(item.box, item.side)) continue;
|
|
2464
|
+
for (const line of lines) {
|
|
2465
|
+
const placed = line.edges?.find(
|
|
2466
|
+
(e) => e.box === item.box && e.side === item.side,
|
|
2467
|
+
);
|
|
2468
|
+
if (placed) {
|
|
2469
|
+
return { line, pen: placed.x + placed.width, broken: false };
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
continue;
|
|
2473
|
+
}
|
|
2474
|
+
if (item.kind === 'atomic') {
|
|
2475
|
+
for (const line of lines) {
|
|
2476
|
+
const placed = line.atomics.find((a) => a.box === item.box);
|
|
2477
|
+
if (!placed) continue;
|
|
2478
|
+
const pen = placed.x + item.box.width + item.box.marginRight;
|
|
2479
|
+
return { line, pen, broken: false };
|
|
604
2480
|
}
|
|
605
|
-
return
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
if (item.kind !== 'text' || item.control) continue;
|
|
2484
|
+
const end = item.start + item.length;
|
|
2485
|
+
const line =
|
|
2486
|
+
lines.find((l) => l.textStart < end && end <= l.textEnd) ??
|
|
2487
|
+
lines[lines.length - 1];
|
|
2488
|
+
const broken = item.run.text.endsWith('\n');
|
|
2489
|
+
return { line, pen: broken ? 0 : caretX(line, end), broken };
|
|
606
2490
|
}
|
|
2491
|
+
return null;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/** Whether an inline box has margin, border or padding on a side. */
|
|
2495
|
+
function hasEdgeOn(box: Box, side: 'start' | 'end'): boolean {
|
|
2496
|
+
const left = (side === 'start') !== (box.style.direction === 'rtl');
|
|
2497
|
+
return left
|
|
2498
|
+
? box.marginLeft !== 0 || box.borderLeft !== 0 || box.padLeft !== 0
|
|
2499
|
+
: box.marginRight !== 0 || box.borderRight !== 0 || box.padRight !== 0;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
/** Where the caret at document offset `at` stands on a line, in the
|
|
2503
|
+
* paragraph's coordinates: the line's end where no text of it holds the
|
|
2504
|
+
* offset. */
|
|
2505
|
+
function caretX(line: LineBox, at: number): number {
|
|
2506
|
+
for (const text of line.texts) {
|
|
2507
|
+
if (at < text.textStart || at > text.textEnd) continue;
|
|
2508
|
+
const offsets = layoutOffsets(text.layout);
|
|
2509
|
+
const units = layoutOffsetOf(text, at, true);
|
|
2510
|
+
const caret = text.layout.caretPosition(codePointAtOffset(offsets, units));
|
|
2511
|
+
return text.drawX + caret.x;
|
|
2512
|
+
}
|
|
2513
|
+
return line.x + line.width;
|
|
607
2514
|
}
|
|
608
2515
|
|
|
609
2516
|
// --- gathering --------------------------------------------------------------
|
|
610
2517
|
|
|
611
|
-
/** Flatten an inline subtree into a stream of runs, atomics and
|
|
612
|
-
|
|
2518
|
+
/** Flatten an inline subtree into a stream of runs, atomics, breaks and the
|
|
2519
|
+
* edges of the inline boxes the text sits in. */
|
|
2520
|
+
/** Gather an inline formatting context's content into `out`, and answer
|
|
2521
|
+
* how many floats it gathered — none unless `floats` asks for them. */
|
|
2522
|
+
function collect(
|
|
2523
|
+
box: Box,
|
|
2524
|
+
out: Item[],
|
|
2525
|
+
width: number,
|
|
2526
|
+
fonts: FontsLike | null,
|
|
2527
|
+
block: ComputedStyle,
|
|
2528
|
+
floats: boolean,
|
|
2529
|
+
/** Where each absolutely positioned box among the content is: before the
|
|
2530
|
+
* item at `index`. No item of the stream, which reads none of them. */
|
|
2531
|
+
statics?: StaticMark[],
|
|
2532
|
+
/** Whether the text is an inline box's that has a margin, border or
|
|
2533
|
+
* padding at a side, which parts it from the text beside it. */
|
|
2534
|
+
apart = false,
|
|
2535
|
+
): number {
|
|
2536
|
+
let floated = 0;
|
|
613
2537
|
for (const child of box.children) {
|
|
614
|
-
if (child.outOfFlow
|
|
2538
|
+
if (child.outOfFlow) {
|
|
2539
|
+
statics?.push({ index: out.length, box: child });
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
if (child.isFloat) {
|
|
2543
|
+
if (floats) {
|
|
2544
|
+
out.push({ kind: 'float', box: child });
|
|
2545
|
+
floated += 1;
|
|
2546
|
+
}
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
615
2549
|
switch (child.kind) {
|
|
616
2550
|
case 'text':
|
|
617
2551
|
if (child.text) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
2552
|
+
const run = runFor(heldText(child), child.style);
|
|
2553
|
+
// shaping is broken across the edge (CSS Text 3, 7.3), and the
|
|
2554
|
+
// engine shapes a word that runs across spans shaped alike as one:
|
|
2555
|
+
// its text is shaped on its own. At both of its sides, the one
|
|
2556
|
+
// with no edge among them, as the engine takes it
|
|
2557
|
+
if (apart) run.shapeApart = true;
|
|
2558
|
+
if (child.style.wordSpacing) {
|
|
2559
|
+
wordSpaced(out, child, run, child.style.wordSpacing);
|
|
2560
|
+
} else {
|
|
2561
|
+
out.push({
|
|
2562
|
+
kind: 'text',
|
|
2563
|
+
run,
|
|
2564
|
+
box: child,
|
|
2565
|
+
length: child.text.length,
|
|
2566
|
+
start: child.textStart,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
624
2569
|
}
|
|
625
2570
|
break;
|
|
626
2571
|
case 'break':
|
|
@@ -641,11 +2586,63 @@ function collect(box: Box, out: Item[]): void {
|
|
|
641
2586
|
},
|
|
642
2587
|
box: child,
|
|
643
2588
|
length: 1,
|
|
2589
|
+
start: child.textStart,
|
|
644
2590
|
});
|
|
645
2591
|
break;
|
|
646
|
-
case 'inline':
|
|
647
|
-
|
|
2592
|
+
case 'inline': {
|
|
2593
|
+
const [start, end] = inlineEdges(child, width, fonts);
|
|
2594
|
+
// Both edges or neither, so that an element with any is bounded on
|
|
2595
|
+
// the line: its pieces are what lies between them, which is how a
|
|
2596
|
+
// right-to-left line finds them (`reorderLine`), and how a rounded
|
|
2597
|
+
// background knows the fragments that open and close it, to round
|
|
2598
|
+
// only those corners — so it gets them even at width zero.
|
|
2599
|
+
// And a box that `position: relative` moves or `vertical-align`
|
|
2600
|
+
// raises has them, so that its text is laid out apart from the text
|
|
2601
|
+
// around it, to be drawn where the box goes (`offsetInline`,
|
|
2602
|
+
// `Lifts`)
|
|
2603
|
+
// and a margin a border cancels is still an edge, which keeps its
|
|
2604
|
+
// line from being one of no height (CSS 2.1 9.4.2)
|
|
2605
|
+
const edged =
|
|
2606
|
+
start !== 0 ||
|
|
2607
|
+
end !== 0 ||
|
|
2608
|
+
hasEdgeOn(child, 'start') ||
|
|
2609
|
+
hasEdgeOn(child, 'end') ||
|
|
2610
|
+
(child.decoration !== null &&
|
|
2611
|
+
child.style.borderRadius.some((r) => r !== 0)) ||
|
|
2612
|
+
isOffset(child.style) ||
|
|
2613
|
+
child.style.verticalAlign !== 'baseline' ||
|
|
2614
|
+
(fonts !== null && ownsLeading(fonts, block, child.style));
|
|
2615
|
+
if (edged) {
|
|
2616
|
+
out.push({ kind: 'edge', box: child, side: 'start', width: start });
|
|
2617
|
+
}
|
|
2618
|
+
// `unicode-bidi` as the controls it stands for, inside the box's
|
|
2619
|
+
// edges and around its text, where it has any
|
|
2620
|
+
const controls =
|
|
2621
|
+
child.style.unicodeBidi !== 'normal' &&
|
|
2622
|
+
child.subtreeTextEnd > child.subtreeTextStart
|
|
2623
|
+
? bidiControls(child.style, false)
|
|
2624
|
+
: null;
|
|
2625
|
+
if (controls) {
|
|
2626
|
+
pushControls(out, controls[0], child, child.subtreeTextStart);
|
|
2627
|
+
}
|
|
2628
|
+
floated += collect(
|
|
2629
|
+
child,
|
|
2630
|
+
out,
|
|
2631
|
+
width,
|
|
2632
|
+
fonts,
|
|
2633
|
+
block,
|
|
2634
|
+
floats,
|
|
2635
|
+
statics,
|
|
2636
|
+
start !== 0 || end !== 0,
|
|
2637
|
+
);
|
|
2638
|
+
if (controls) {
|
|
2639
|
+
pushControls(out, controls[1], child, child.subtreeTextEnd);
|
|
2640
|
+
}
|
|
2641
|
+
if (edged) {
|
|
2642
|
+
out.push({ kind: 'edge', box: child, side: 'end', width: end });
|
|
2643
|
+
}
|
|
648
2644
|
break;
|
|
2645
|
+
}
|
|
649
2646
|
default:
|
|
650
2647
|
// Everything else that reached an inline context is inline-level and
|
|
651
2648
|
// atomic: a replaced element, an `inline-block`, an `inline-table`.
|
|
@@ -653,54 +2650,1011 @@ function collect(box: Box, out: Item[]): void {
|
|
|
653
2650
|
break;
|
|
654
2651
|
}
|
|
655
2652
|
}
|
|
2653
|
+
return floated;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
/**
|
|
2657
|
+
* What a paragraph does with a word too long for its line: cuts it where
|
|
2658
|
+
* its style or any text in it says a word may be cut — `overflow-wrap`, or
|
|
2659
|
+
* `word-break` or `line-break: anywhere`, which cut one whatever
|
|
2660
|
+
* `overflow-wrap` says (CSS Text 3, 5.2 and 5.3) — and lets it run past the
|
|
2661
|
+
* line's end where nothing does, as a browser does. The engine takes one answer for the paragraph, so a span
|
|
2662
|
+
* that breaks its words breaks the paragraph's.
|
|
2663
|
+
*/
|
|
2664
|
+
function overflowWrapOf(
|
|
2665
|
+
style: ComputedStyle,
|
|
2666
|
+
items: readonly Item[],
|
|
2667
|
+
): 'normal' | 'break-word' {
|
|
2668
|
+
if (breaksWords(style)) return 'break-word';
|
|
2669
|
+
for (const item of items) {
|
|
2670
|
+
if (item.kind === 'text' && breaksWords(item.box.style)) {
|
|
2671
|
+
return 'break-word';
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
return 'normal';
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/** A `text-overflow` cut on a line that does not wrap puts its ellipsis at
|
|
2678
|
+
* the box's edge, inside a word where one runs past it, as a browser
|
|
2679
|
+
* does — which is the engine cutting the word there. */
|
|
2680
|
+
function cutWrap(
|
|
2681
|
+
style: ComputedStyle,
|
|
2682
|
+
cut: Cut | null | undefined,
|
|
2683
|
+
wrapWords: 'normal' | 'break-word',
|
|
2684
|
+
): 'normal' | 'break-word' {
|
|
2685
|
+
return cut && !wraps(style) ? 'break-word' : wrapWords;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
function breaksWords(style: ComputedStyle): boolean {
|
|
2689
|
+
return (
|
|
2690
|
+
style.overflowWrap !== 'normal' ||
|
|
2691
|
+
style.wordBreak === 'break-all' ||
|
|
2692
|
+
style.wordBreak === 'break-word' ||
|
|
2693
|
+
style.lineBreakAnywhere
|
|
2694
|
+
);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/**
|
|
2698
|
+
* Keep the text of a `nowrap` element together where its block wraps (CSS
|
|
2699
|
+
* 2.1 16.6). Its runs share the element as their `nowrap`, which ntk makes
|
|
2700
|
+
* no break inside or between; and, for an engine that does not read it,
|
|
2701
|
+
* a space it may not break after is a no-break space, as wide and as many,
|
|
2702
|
+
* so every offset holds. Whether a line breaks between two characters is
|
|
2703
|
+
* the call of the nearest element holding both (CSS Text 3, 5.1), so the
|
|
2704
|
+
* space such an element ends on stays one to break at where the text after
|
|
2705
|
+
* it is not the same element's: text that wraps, or another `nowrap`
|
|
2706
|
+
* element's — two tags side by side, a collapsed space between them.
|
|
2707
|
+
*/
|
|
2708
|
+
function holdNoWrap(items: Item[], block: Box): void {
|
|
2709
|
+
let last = true;
|
|
2710
|
+
let nextGroup: Box | null = null;
|
|
2711
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
2712
|
+
const item = items[i];
|
|
2713
|
+
if (item.kind !== 'text') continue;
|
|
2714
|
+
const group = wraps(item.box.style) ? null : noWrapGroup(item.box, block);
|
|
2715
|
+
if (group) {
|
|
2716
|
+
const text = item.run.text;
|
|
2717
|
+
let held = text;
|
|
2718
|
+
// `pre`'s are no-break spaces already (`runFor`)
|
|
2719
|
+
if (item.box.style.whiteSpace === 'nowrap' && text.includes(' ')) {
|
|
2720
|
+
held = text.replace(/ /g, '\u00a0');
|
|
2721
|
+
if (text.endsWith(' ') && (last || nextGroup !== group)) {
|
|
2722
|
+
held = held.slice(0, -1) + ' ';
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
item.run = { ...item.run, text: held, nowrap: group };
|
|
2726
|
+
}
|
|
2727
|
+
last = false;
|
|
2728
|
+
nextGroup = group;
|
|
2729
|
+
}
|
|
656
2730
|
}
|
|
657
2731
|
|
|
658
|
-
/** The
|
|
659
|
-
|
|
660
|
-
|
|
2732
|
+
/** The element a text that does not wrap is held together by: the outermost
|
|
2733
|
+
* one inside the block that does not wrap either. */
|
|
2734
|
+
function noWrapGroup(box: Box, block: Box): Box {
|
|
2735
|
+
let group = box;
|
|
2736
|
+
for (let up = box.parent; up && up !== block; up = up.parent) {
|
|
2737
|
+
if (wraps(up.style)) break;
|
|
2738
|
+
group = up;
|
|
2739
|
+
}
|
|
2740
|
+
return group;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/** The bidi controls `unicode-bidi` stands for, opening and closing (CSS
|
|
2744
|
+
* Writing Modes 3, 2.4.2), in the direction of the box; null for `normal`.
|
|
2745
|
+
* On a block only an override opens anything: the block's embedding is
|
|
2746
|
+
* its paragraph's direction. */
|
|
2747
|
+
function bidiControls(
|
|
2748
|
+
style: ComputedStyle,
|
|
2749
|
+
block: boolean,
|
|
2750
|
+
): [string, string] | null {
|
|
2751
|
+
const rtl = style.direction === 'rtl';
|
|
2752
|
+
const override = rtl ? '\u202E' : '\u202D';
|
|
2753
|
+
switch (style.unicodeBidi) {
|
|
2754
|
+
case 'embed':
|
|
2755
|
+
return [rtl ? '\u202B' : '\u202A', '\u202C'];
|
|
2756
|
+
case 'bidi-override':
|
|
2757
|
+
return [override, '\u202C'];
|
|
2758
|
+
case 'isolate':
|
|
2759
|
+
return [rtl ? '\u2067' : '\u2066', '\u2069'];
|
|
2760
|
+
case 'isolate-override':
|
|
2761
|
+
return block
|
|
2762
|
+
? [override, '\u202C']
|
|
2763
|
+
: [(rtl ? '\u2067' : '\u2066') + override, '\u202C\u2069'];
|
|
2764
|
+
case 'plaintext':
|
|
2765
|
+
return ['\u2068', '\u2069'];
|
|
2766
|
+
default:
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/** Bidi controls as items of their own, one a unit, at a document index
|
|
2772
|
+
* none of them takes up. */
|
|
2773
|
+
function pushControls(
|
|
2774
|
+
out: Item[],
|
|
2775
|
+
controls: string,
|
|
2776
|
+
box: Box,
|
|
2777
|
+
start: number,
|
|
2778
|
+
): void {
|
|
2779
|
+
for (const control of controls) {
|
|
2780
|
+
out.push({
|
|
2781
|
+
kind: 'text',
|
|
2782
|
+
run: runFor(control, box.style),
|
|
2783
|
+
box,
|
|
2784
|
+
length: 1,
|
|
2785
|
+
start,
|
|
2786
|
+
control: true,
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function hasControls(items: Item[]): boolean {
|
|
2792
|
+
for (const item of items)
|
|
2793
|
+
if (item.kind === 'text' && item.control) return true;
|
|
2794
|
+
return false;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* A text box with `word-spacing`, as runs of its words and of the spaces
|
|
2799
|
+
* between them, each space run carrying the extra as letter spacing: the
|
|
2800
|
+
* engines space letters, not words, and a space's letter spacing is exactly
|
|
2801
|
+
* the room CSS adds after a word separator (16.4 — the space and the
|
|
2802
|
+
* no-break space). Only text that asks for it is split.
|
|
2803
|
+
*/
|
|
2804
|
+
function wordSpaced(
|
|
2805
|
+
out: Item[],
|
|
2806
|
+
box: Box,
|
|
2807
|
+
run: TextRun,
|
|
2808
|
+
spacing: number,
|
|
2809
|
+
): void {
|
|
2810
|
+
const text = box.text;
|
|
2811
|
+
const push = (from: number, to: number, extra: number): void => {
|
|
2812
|
+
if (to <= from) return;
|
|
2813
|
+
out.push({
|
|
2814
|
+
kind: 'text',
|
|
2815
|
+
run: {
|
|
2816
|
+
...run,
|
|
2817
|
+
text: text.slice(from, to),
|
|
2818
|
+
...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
|
|
2819
|
+
},
|
|
2820
|
+
box,
|
|
2821
|
+
length: to - from,
|
|
2822
|
+
start: box.textStart + from,
|
|
2823
|
+
});
|
|
2824
|
+
};
|
|
2825
|
+
let from = 0;
|
|
2826
|
+
for (const m of text.matchAll(/[ \u00A0]+/g)) {
|
|
2827
|
+
push(from, m.index, 0);
|
|
2828
|
+
push(m.index, m.index + m[0].length, spacing);
|
|
2829
|
+
from = m.index + m[0].length;
|
|
2830
|
+
}
|
|
2831
|
+
push(from, text.length, 0);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/**
|
|
2835
|
+
* An inline box's margin, border and padding, resolved onto it — percentages
|
|
2836
|
+
* of the containing block's width, as a block's are — and the widths of its
|
|
2837
|
+
* two edges on the line: the start side's before its first fragment, the
|
|
2838
|
+
* end side's after its last. One with a background or a border to paint
|
|
2839
|
+
* also notes its face's ascent and descent, the height CSS paints them over
|
|
2840
|
+
* (10.6.1, the content area), so paint needs no font.
|
|
2841
|
+
*/
|
|
2842
|
+
function inlineEdges(
|
|
2843
|
+
box: Box,
|
|
2844
|
+
width: number,
|
|
2845
|
+
fonts: FontsLike | null,
|
|
2846
|
+
): [number, number] {
|
|
2847
|
+
const s = box.style;
|
|
2848
|
+
box.borderTop = usedBorder(s.borderTopWidth, s.borderTopStyle);
|
|
2849
|
+
box.borderRight = usedBorder(s.borderRightWidth, s.borderRightStyle);
|
|
2850
|
+
box.borderBottom = usedBorder(s.borderBottomWidth, s.borderBottomStyle);
|
|
2851
|
+
box.borderLeft = usedBorder(s.borderLeftWidth, s.borderLeftStyle);
|
|
2852
|
+
box.padTop = resolve(s.paddingTop, width);
|
|
2853
|
+
box.padRight = resolve(s.paddingRight, width);
|
|
2854
|
+
box.padBottom = resolve(s.paddingBottom, width);
|
|
2855
|
+
box.padLeft = resolve(s.paddingLeft, width);
|
|
2856
|
+
box.marginLeft = resolve(s.marginLeft, width);
|
|
2857
|
+
box.marginRight = resolve(s.marginRight, width);
|
|
2858
|
+
if (box.cut) {
|
|
2859
|
+
// a piece of a box broken around a block has no edge where it was cut
|
|
2860
|
+
const rtl = s.direction === 'rtl';
|
|
2861
|
+
if (box.cut & (rtl ? 2 : 1)) {
|
|
2862
|
+
box.marginLeft = box.borderLeft = box.padLeft = 0;
|
|
2863
|
+
}
|
|
2864
|
+
if (box.cut & (rtl ? 1 : 2)) {
|
|
2865
|
+
box.marginRight = box.borderRight = box.padRight = 0;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
box.decoration = inlineDecoration(fonts, box, s);
|
|
2869
|
+
const left = box.marginLeft + box.borderLeft + box.padLeft;
|
|
2870
|
+
const right = box.padRight + box.borderRight + box.marginRight;
|
|
2871
|
+
return s.direction === 'rtl' ? [right, left] : [left, right];
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* What an inline box draws around its text, in `s`: the extent of its face
|
|
2876
|
+
* where it has a background, a border or an outline, and null where it
|
|
2877
|
+
* draws none. Its borders are the ones `inlineEdges` just used. Shared with
|
|
2878
|
+
* a pointer move that gives a link a background (`HtmlViewNode`).
|
|
2879
|
+
*/
|
|
2880
|
+
export function inlineDecoration(
|
|
2881
|
+
fonts: FontsLike | null,
|
|
2882
|
+
box: Box,
|
|
2883
|
+
s: ComputedStyle,
|
|
2884
|
+
): InlineDecoration | null {
|
|
2885
|
+
// a background is a colour, a gradient or an image: a span a gradient
|
|
2886
|
+
// underlines, a highlighter's, was painted only where it had a border
|
|
2887
|
+
const decorated =
|
|
2888
|
+
!isTransparent(s.backgroundColor) ||
|
|
2889
|
+
s.backgroundGradient !== null ||
|
|
2890
|
+
s.backgroundImage !== null ||
|
|
2891
|
+
s.backgroundImages !== null ||
|
|
2892
|
+
box.borderTop + box.borderRight + box.borderBottom + box.borderLeft > 0 ||
|
|
2893
|
+
s.outlineStyle !== 'none';
|
|
2894
|
+
return !decorated ? null : fonts ? faceExtent(fonts, s) : NO_EXTENT;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
/** A border's width, or none where its style draws none. */
|
|
2898
|
+
function usedBorder(
|
|
2899
|
+
width: number,
|
|
2900
|
+
style: ComputedStyle['borderTopStyle'],
|
|
2901
|
+
): number {
|
|
2902
|
+
return style === 'none' || style === 'hidden' ? 0 : width;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/** A face's ascent and descent at a style's size, kept per font manager
|
|
2906
|
+
* and shared by the boxes set in it: by the style, which the cascade
|
|
2907
|
+
* shares between every element of a kind, and by the face, which styles
|
|
2908
|
+
* that differ in anything else share. */
|
|
2909
|
+
const FACE_EXTENTS = new WeakMap<
|
|
2910
|
+
FontsLike,
|
|
2911
|
+
{
|
|
2912
|
+
byStyle: WeakMap<ComputedStyle, InlineDecoration>;
|
|
2913
|
+
byFace: Map<string, InlineDecoration>;
|
|
2914
|
+
}
|
|
2915
|
+
>();
|
|
2916
|
+
|
|
2917
|
+
/** The extent of a decorated box where there are no fonts to ask. */
|
|
2918
|
+
const NO_EXTENT: InlineDecoration = { ascent: 0, descent: 0 };
|
|
2919
|
+
|
|
2920
|
+
/** A style's face's ascent and descent, which an inline box's background
|
|
2921
|
+
* is painted over. */
|
|
2922
|
+
export function faceExtentOf(
|
|
2923
|
+
fonts: FontsLike,
|
|
2924
|
+
style: ComputedStyle,
|
|
2925
|
+
): InlineDecoration {
|
|
2926
|
+
return faceExtent(fonts, style);
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function faceExtent(fonts: FontsLike, style: ComputedStyle): InlineDecoration {
|
|
2930
|
+
let cache = FACE_EXTENTS.get(fonts);
|
|
2931
|
+
if (!cache) {
|
|
2932
|
+
cache = { byStyle: new WeakMap(), byFace: new Map() };
|
|
2933
|
+
FACE_EXTENTS.set(fonts, cache);
|
|
2934
|
+
}
|
|
2935
|
+
let extent = cache.byStyle.get(style);
|
|
2936
|
+
if (extent) return extent;
|
|
2937
|
+
const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight}|${style.fontStyle}`;
|
|
2938
|
+
extent = cache.byFace.get(key);
|
|
2939
|
+
if (!extent) {
|
|
2940
|
+
try {
|
|
2941
|
+
const m = fonts
|
|
2942
|
+
.match(style.fontFamily, {
|
|
2943
|
+
size: style.fontSize,
|
|
2944
|
+
weight: style.fontWeight,
|
|
2945
|
+
style: style.fontStyle,
|
|
2946
|
+
})
|
|
2947
|
+
.metrics(style.fontSize);
|
|
2948
|
+
extent = { ascent: m.ascent, descent: m.descent };
|
|
2949
|
+
} catch {
|
|
2950
|
+
extent = { ascent: style.fontSize * 0.8, descent: style.fontSize * 0.2 };
|
|
2951
|
+
}
|
|
2952
|
+
if (cache.byFace.size > 64) cache.byFace.clear();
|
|
2953
|
+
cache.byFace.set(key, extent);
|
|
2954
|
+
}
|
|
2955
|
+
cache.byStyle.set(style, extent);
|
|
2956
|
+
return extent;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** A first line in its `::first-line` fonts (`firstLineSetting`). */
|
|
2960
|
+
interface FirstLineSetting {
|
|
2961
|
+
/** The content, its text in the styles its boxes have on the line. */
|
|
2962
|
+
items: Item[];
|
|
2963
|
+
base: Record<string, unknown>;
|
|
2964
|
+
lineHeight: number;
|
|
2965
|
+
strut: InlineDecoration | null;
|
|
2966
|
+
lifts: Lifts;
|
|
2967
|
+
justify: LineJustify | null;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* What a first line is made with where its `::first-line` sets its fonts
|
|
2972
|
+
* (CSS 2.1 5.12.1). Its text is in the styles its boxes have on it — each
|
|
2973
|
+
* element's own rules over what it inherits there, which takes the fonts
|
|
2974
|
+
* from `::first-line` (CSS Pseudo 4, 2.1.2), so a `<small>` is smaller
|
|
2975
|
+
* than the line's font and not than the block's — and it is laid out at
|
|
2976
|
+
* the pseudo-element's line height. The pseudo-element is an inline box
|
|
2977
|
+
* around the line's content, so its face at its line height is on the line
|
|
2978
|
+
* with the block's strut, which stays: a `::first-line` of a smaller line
|
|
2979
|
+
* height leaves the line as tall as the block's.
|
|
2980
|
+
*/
|
|
2981
|
+
function firstLineSetting(
|
|
2982
|
+
block: Box,
|
|
2983
|
+
items: Item[],
|
|
2984
|
+
how: NonNullable<InlineOptions['firstLineStyle']>,
|
|
2985
|
+
fonts: FontsLike,
|
|
2986
|
+
strut: InlineDecoration | null,
|
|
2987
|
+
justify: LineJustify | null,
|
|
2988
|
+
): FirstLineSetting {
|
|
2989
|
+
const style = how.style;
|
|
2990
|
+
const of = (box: Box): ComputedStyle =>
|
|
2991
|
+
box === block || !box.parent
|
|
2992
|
+
? style
|
|
2993
|
+
: styleOnFirstLine(box, of(box.parent), how.styler);
|
|
2994
|
+
// and in its `text-transform` where the line has one of its own, which
|
|
2995
|
+
// the boxes' text was not made with: a word carried from one run into
|
|
2996
|
+
// the next, as the box builder carries it (`letteredAfter`)
|
|
2997
|
+
let lettered = false;
|
|
2998
|
+
const restyled = items.map((item): Item => {
|
|
2999
|
+
if (item.kind === 'atomic') lettered = false;
|
|
3000
|
+
if (item.kind !== 'text') return item;
|
|
3001
|
+
const own = item.box.style;
|
|
3002
|
+
const on = of(item.box);
|
|
3003
|
+
let run = restyledRun(item.run, own, on);
|
|
3004
|
+
const transform = on.textTransform;
|
|
3005
|
+
if (
|
|
3006
|
+
!item.control &&
|
|
3007
|
+
transform !== own.textTransform &&
|
|
3008
|
+
transform !== 'none'
|
|
3009
|
+
) {
|
|
3010
|
+
const text = transformInPlace(run.text, transform, lettered);
|
|
3011
|
+
if (text !== run.text) run = { ...run, text };
|
|
3012
|
+
}
|
|
3013
|
+
lettered = letteredAfter(run.text, lettered);
|
|
3014
|
+
return run === item.run ? item : { ...item, run };
|
|
3015
|
+
});
|
|
3016
|
+
const lineHeight = lineHeightMultiplier(fonts, style);
|
|
3017
|
+
const base = {
|
|
3018
|
+
family: style.fontFamily,
|
|
3019
|
+
size: style.fontSize,
|
|
3020
|
+
weight: style.fontWeight,
|
|
3021
|
+
style: style.fontStyle,
|
|
3022
|
+
color: style.color,
|
|
3023
|
+
};
|
|
3024
|
+
const own = strutOf(fonts, style);
|
|
3025
|
+
return {
|
|
3026
|
+
items: restyled,
|
|
3027
|
+
base,
|
|
3028
|
+
lineHeight,
|
|
3029
|
+
strut: strut && {
|
|
3030
|
+
ascent: Math.max(strut.ascent, own.ascent),
|
|
3031
|
+
descent: Math.max(strut.descent, own.descent),
|
|
3032
|
+
},
|
|
3033
|
+
lifts: new Lifts(fonts, style, of),
|
|
3034
|
+
justify: justify && {
|
|
3035
|
+
...justify,
|
|
3036
|
+
base,
|
|
3037
|
+
options: { ...justify.options, lineHeight },
|
|
3038
|
+
},
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* Each box's style on a first line (`FirstLineStyler`), kept for the style
|
|
3044
|
+
* its parent has there: every pass asks again, and a style that is a new
|
|
3045
|
+
* object each time is a new face to every cache that keys on one.
|
|
3046
|
+
*/
|
|
3047
|
+
const ON_FIRST_LINE = new WeakMap<
|
|
3048
|
+
Box,
|
|
3049
|
+
{ parent: ComputedStyle; style: ComputedStyle }
|
|
3050
|
+
>();
|
|
3051
|
+
|
|
3052
|
+
/** A box's style on a first line, where its parent's there is `parent`. */
|
|
3053
|
+
function styleOnFirstLine(
|
|
3054
|
+
box: Box,
|
|
3055
|
+
parent: ComputedStyle,
|
|
3056
|
+
styler: FirstLineStyler,
|
|
3057
|
+
): ComputedStyle {
|
|
3058
|
+
const known = ON_FIRST_LINE.get(box);
|
|
3059
|
+
if (known && known.parent === parent) return known.style;
|
|
3060
|
+
const style = styler(box, parent);
|
|
3061
|
+
ON_FIRST_LINE.set(box, { parent, style });
|
|
3062
|
+
return style;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
/** What a run takes from its style (`runFor`). */
|
|
3066
|
+
const RUN_STYLE = [
|
|
3067
|
+
'family',
|
|
3068
|
+
'size',
|
|
3069
|
+
'weight',
|
|
3070
|
+
'style',
|
|
3071
|
+
'color',
|
|
3072
|
+
'letterSpacing',
|
|
3073
|
+
'features',
|
|
3074
|
+
'underline',
|
|
3075
|
+
'underlineStyle',
|
|
3076
|
+
'underlineOffset',
|
|
3077
|
+
'underlineThickness',
|
|
3078
|
+
'strike',
|
|
3079
|
+
] as const;
|
|
3080
|
+
|
|
3081
|
+
/**
|
|
3082
|
+
* A run of text in `style` rather than `own`, the style it was made in:
|
|
3083
|
+
* what it has of neither — the room a tab or `word-spacing` adds to its
|
|
3084
|
+
* letter spacing, a `nowrap` group, where its shaping is broken — kept.
|
|
3085
|
+
*/
|
|
3086
|
+
function restyledRun(
|
|
3087
|
+
run: TextRun,
|
|
3088
|
+
own: ComputedStyle,
|
|
661
3089
|
style: ComputedStyle,
|
|
662
|
-
owner: Element | null,
|
|
663
3090
|
): TextRun {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
3091
|
+
if (style === own) return run;
|
|
3092
|
+
const fresh = runFor(run.text, style) as unknown as Record<string, unknown>;
|
|
3093
|
+
const out = { ...run } as unknown as Record<string, unknown>;
|
|
3094
|
+
for (const name of RUN_STYLE) {
|
|
3095
|
+
if (fresh[name] === undefined) delete out[name];
|
|
3096
|
+
else out[name] = fresh[name];
|
|
3097
|
+
}
|
|
3098
|
+
const added = (run.letterSpacing ?? 0) - (own.letterSpacing || 0);
|
|
3099
|
+
if (added) out.letterSpacing = ((out.letterSpacing as number) ?? 0) + added;
|
|
3100
|
+
return out as unknown as TextRun;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* The first line's text in its `::first-line` colour: the runs before the
|
|
3105
|
+
* line's end whose colour is the block's own, the one that ends past it cut
|
|
3106
|
+
* there. A run in a colour of its own — a link's — keeps it, as an element
|
|
3107
|
+
* inside the pseudo-element does. Colour moves no glyph, so the lines break
|
|
3108
|
+
* where they did.
|
|
3109
|
+
*/
|
|
3110
|
+
function firstLineColour(
|
|
3111
|
+
items: Item[],
|
|
3112
|
+
firstLine: { color: string; from: string; end: number },
|
|
3113
|
+
): void {
|
|
3114
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
3115
|
+
const item = items[i];
|
|
3116
|
+
if (item.kind !== 'text') continue;
|
|
3117
|
+
// past the line: a run in a colour of its own may have been the one
|
|
3118
|
+
// across its end, so this is where the walk stops, not at the cut
|
|
3119
|
+
if (item.start >= firstLine.end) break;
|
|
3120
|
+
if (item.run.color !== firstLine.from) continue;
|
|
3121
|
+
const cut = firstLine.end - item.start;
|
|
3122
|
+
if (cut < item.length) {
|
|
3123
|
+
items.splice(i + 1, 0, {
|
|
3124
|
+
...item,
|
|
3125
|
+
run: { ...item.run, text: item.run.text.slice(cut) },
|
|
3126
|
+
start: item.start + cut,
|
|
3127
|
+
length: item.length - cut,
|
|
3128
|
+
});
|
|
3129
|
+
items[i] = {
|
|
3130
|
+
...item,
|
|
3131
|
+
run: {
|
|
3132
|
+
...item.run,
|
|
3133
|
+
text: item.run.text.slice(0, cut),
|
|
3134
|
+
color: firstLine.color,
|
|
3135
|
+
},
|
|
3136
|
+
length: cut,
|
|
3137
|
+
};
|
|
3138
|
+
break;
|
|
3139
|
+
}
|
|
3140
|
+
items[i] = { ...item, run: { ...item.run, color: firstLine.color } };
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
/** The `TextRun` one styled piece of text becomes: its text, and what paint
|
|
3145
|
+
* needs of its style — nothing that names a box or an element, so that a
|
|
3146
|
+
* layout made from it can be kept for the next parse (`TextLayoutCache`). */
|
|
3147
|
+
/**
|
|
3148
|
+
* A text box's text as it is laid out. `pre` keeps its spaces whole: none
|
|
3149
|
+
* is broken after, and none hangs at a line's end, where a trailing space
|
|
3150
|
+
* counts in the line's width. Laid out, they are no-break spaces, as wide
|
|
3151
|
+
* and as many, so every offset holds (CSS Text 3, 4.1.3). Kept per box: a
|
|
3152
|
+
* pass makes no new string, and the kept layouts find their text by
|
|
3153
|
+
* identity before they compare it.
|
|
3154
|
+
*/
|
|
3155
|
+
/**
|
|
3156
|
+
* Tabs a `white-space` keeps, set at their stops (CSS Text 3, 4.2): a stop
|
|
3157
|
+
* every `tab-size` spaces of the block's font from the line's start, and a
|
|
3158
|
+
* tab that would reach one less than half a `ch` on goes to the next.
|
|
3159
|
+
* Neither engine has them — ntk draws a tab a space wide, and CoreText
|
|
3160
|
+
* sets it at stops of its own every 28 points — so a tab is laid out as a
|
|
3161
|
+
* space spaced out to its stop: text as the engine sees it and not as the
|
|
3162
|
+
* document holds it, the way `heldText` holds a `pre`'s spaces. Where each
|
|
3163
|
+
* tab starts is read off one layout of the paragraph with its tabs as
|
|
3164
|
+
* spaces, and its inline boxes' edges and its atomics as the room they
|
|
3165
|
+
* take, unwrapped: a line `pre-wrap` wraps sets the tabs after the wrap as
|
|
3166
|
+
* though it had not, and a right-to-left one sets them a space wide.
|
|
3167
|
+
*
|
|
3168
|
+
* Read off each tab's run, which the tab is given of its own, and not off
|
|
3169
|
+
* a caret: CoreText puts a caret after a spaced glyph part of the way
|
|
3170
|
+
* into its spacing, so a tab after `word-spacing` was set from a place
|
|
3171
|
+
* half the spacing short of where it is drawn.
|
|
3172
|
+
*/
|
|
3173
|
+
function setTabs(
|
|
3174
|
+
items: Item[],
|
|
3175
|
+
fonts: FontsLike,
|
|
3176
|
+
block: ComputedStyle,
|
|
3177
|
+
indent: number,
|
|
3178
|
+
): void {
|
|
3179
|
+
// the paragraph as it will be laid out, an inline box's edges and an
|
|
3180
|
+
// atomic as the room they take, each tab a run of its own, and where
|
|
3181
|
+
// each text item starts in it
|
|
3182
|
+
const runs: TextRun[] = [];
|
|
3183
|
+
const starts = new Map<Item, number>();
|
|
3184
|
+
const tabs = new Set<number>();
|
|
3185
|
+
let units = 0;
|
|
3186
|
+
for (const item of items) {
|
|
3187
|
+
// a float is beside the line, and takes none of it
|
|
3188
|
+
if (item.kind === 'float') continue;
|
|
3189
|
+
if (item.kind !== 'text') {
|
|
3190
|
+
const box = item.box;
|
|
3191
|
+
runs.push(
|
|
3192
|
+
spacerRun(
|
|
3193
|
+
fonts,
|
|
3194
|
+
block,
|
|
3195
|
+
item.kind === 'edge'
|
|
3196
|
+
? item.width
|
|
3197
|
+
: box.width + box.marginLeft + box.marginRight,
|
|
3198
|
+
),
|
|
3199
|
+
);
|
|
3200
|
+
units += 1;
|
|
3201
|
+
continue;
|
|
3202
|
+
}
|
|
3203
|
+
starts.set(item, units);
|
|
3204
|
+
const text = item.run.text;
|
|
3205
|
+
if (item.control || !text.includes('\t')) {
|
|
3206
|
+
runs.push(item.run);
|
|
3207
|
+
units += text.length;
|
|
3208
|
+
continue;
|
|
3209
|
+
}
|
|
3210
|
+
let done = 0;
|
|
3211
|
+
for (let p = text.indexOf('\t'); p >= 0; p = text.indexOf('\t', p + 1)) {
|
|
3212
|
+
if (p > done) runs.push({ ...item.run, text: text.slice(done, p) });
|
|
3213
|
+
// spaced a hair apart from its neighbours, which an engine that
|
|
3214
|
+
// merges runs alike (CoreText) would otherwise take it into
|
|
3215
|
+
tabs.add(units + p);
|
|
3216
|
+
runs.push({
|
|
3217
|
+
...item.run,
|
|
3218
|
+
text: spaceIn(item.box),
|
|
3219
|
+
letterSpacing: (item.run.letterSpacing ?? 0) + tabs.size * HAIR,
|
|
3220
|
+
});
|
|
3221
|
+
done = p + 1;
|
|
3222
|
+
}
|
|
3223
|
+
if (done < text.length) runs.push({ ...item.run, text: text.slice(done) });
|
|
3224
|
+
units += text.length;
|
|
3225
|
+
}
|
|
3226
|
+
const natural = fonts.layout(runs, fontOf(block), {
|
|
3227
|
+
direction: block.direction,
|
|
3228
|
+
});
|
|
3229
|
+
/** Where each tab starts, by its place in the layout's text. */
|
|
3230
|
+
const found = new Map<number, { x: number; line: number }>();
|
|
3231
|
+
natural.lines.forEach((line, i) => {
|
|
3232
|
+
for (const run of line.runs ?? []) {
|
|
3233
|
+
if (tabs.has(run.start))
|
|
3234
|
+
found.set(run.start, { x: line.x + run.x, line: i });
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
let offsets: number[] | null | undefined;
|
|
3238
|
+
const space =
|
|
3239
|
+
advanceOf(fonts, fontOf(block), ' ') +
|
|
3240
|
+
block.letterSpacing +
|
|
3241
|
+
block.wordSpacing;
|
|
3242
|
+
const every = block.tabSizeIsLength ? block.tabSize : block.tabSize * space;
|
|
3243
|
+
const half = advanceOf(fonts, fontOf(block), '0') / 2;
|
|
3244
|
+
const rtl = block.direction === 'rtl';
|
|
3245
|
+
/** The room tabs have added to each line so far. */
|
|
3246
|
+
const added = new Map<number, number>();
|
|
3247
|
+
const next: Item[] = [];
|
|
3248
|
+
for (const item of items) {
|
|
3249
|
+
if (item.kind !== 'text' || item.control || !item.run.text.includes('\t')) {
|
|
3250
|
+
next.push(item);
|
|
3251
|
+
continue;
|
|
3252
|
+
}
|
|
3253
|
+
const text = item.run.text;
|
|
3254
|
+
const at = starts.get(item)!;
|
|
3255
|
+
// what the space a tab is laid out as takes before it is spaced out
|
|
3256
|
+
const own =
|
|
3257
|
+
advanceOf(fonts, item.run, spaceIn(item.box)) +
|
|
3258
|
+
(item.run.letterSpacing ?? 0);
|
|
3259
|
+
const piece = (start: number, end: number, run: TextRun): void => {
|
|
3260
|
+
next.push({
|
|
3261
|
+
...item,
|
|
3262
|
+
run,
|
|
3263
|
+
length: end - start,
|
|
3264
|
+
start: item.start + start,
|
|
3265
|
+
});
|
|
3266
|
+
};
|
|
3267
|
+
let done = 0;
|
|
3268
|
+
for (let p = text.indexOf('\t'); p >= 0; p = text.indexOf('\t', p + 1)) {
|
|
3269
|
+
if (p > done) piece(done, p, { ...item.run, text: text.slice(done, p) });
|
|
3270
|
+
let advance = own;
|
|
3271
|
+
if (!rtl) {
|
|
3272
|
+
const unit = at + p;
|
|
3273
|
+
let caret = found.get(unit);
|
|
3274
|
+
if (!caret) {
|
|
3275
|
+
// an engine that says nothing of its runs has a caret to go by
|
|
3276
|
+
if (offsets === undefined) {
|
|
3277
|
+
const joined = runs.map((run) => run.text).join('');
|
|
3278
|
+
offsets = /[\uD800-\uDFFF]/.test(joined)
|
|
3279
|
+
? codeUnitOffsets(joined)
|
|
3280
|
+
: null;
|
|
3281
|
+
}
|
|
3282
|
+
caret = natural.caretPosition(
|
|
3283
|
+
offsets ? codePointAt(offsets, unit) : unit,
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
const before = added.get(caret.line) ?? 0;
|
|
3287
|
+
const x = caret.x + before + (caret.line === 0 ? indent : 0);
|
|
3288
|
+
if (every > 0) {
|
|
3289
|
+
let stop = (Math.floor(x / every) + 1) * every;
|
|
3290
|
+
if (stop - x < half) stop += every;
|
|
3291
|
+
advance = stop - x;
|
|
3292
|
+
} else advance = 0;
|
|
3293
|
+
added.set(caret.line, before + advance - own);
|
|
3294
|
+
}
|
|
3295
|
+
piece(p, p + 1, {
|
|
3296
|
+
...item.run,
|
|
3297
|
+
text: spaceIn(item.box),
|
|
3298
|
+
letterSpacing: (item.run.letterSpacing ?? 0) + advance - own,
|
|
3299
|
+
});
|
|
3300
|
+
done = p + 1;
|
|
3301
|
+
}
|
|
3302
|
+
if (done < text.length) {
|
|
3303
|
+
piece(done, text.length, { ...item.run, text: text.slice(done) });
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
items.splice(0, items.length, ...next);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
/** The space a tab in a box is laid out as: a `pre`'s spaces are held
|
|
3310
|
+
* together (`heldText`), and so are its tabs. */
|
|
3311
|
+
function spaceIn(box: Box): string {
|
|
3312
|
+
return box.style.whiteSpace === 'pre' ? '\u00a0' : ' ';
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
/** A text's font, the part of a run a character's advance depends on. */
|
|
3316
|
+
type Face = Pick<TextRun, 'family' | 'size' | 'weight' | 'style'>;
|
|
3317
|
+
|
|
3318
|
+
/** A block's font, as its runs name it (`runFor`). */
|
|
3319
|
+
function fontOf(style: ComputedStyle): Face {
|
|
3320
|
+
return {
|
|
3321
|
+
family: style.fontFamily,
|
|
3322
|
+
size: style.fontSize,
|
|
3323
|
+
weight: style.fontWeight,
|
|
3324
|
+
style: style.fontStyle === 'normal' ? 'normal' : 'italic',
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
/** One character's advance in a font, kept per font manager. */
|
|
3329
|
+
function advanceOf(fonts: FontsLike, font: Face, char: string): number {
|
|
3330
|
+
let kept = ADVANCES.get(fonts);
|
|
3331
|
+
if (!kept) ADVANCES.set(fonts, (kept = new Map()));
|
|
3332
|
+
const key = `${font.family}|${font.size}|${font.weight}|${font.style}|${char}`;
|
|
3333
|
+
let advance = kept.get(key);
|
|
3334
|
+
if (advance === undefined) {
|
|
3335
|
+
const face: Face = {
|
|
3336
|
+
family: font.family,
|
|
3337
|
+
size: font.size,
|
|
3338
|
+
weight: font.weight,
|
|
3339
|
+
style: font.style,
|
|
3340
|
+
};
|
|
3341
|
+
// between two letters, so that no engine drops it as a line's end
|
|
3342
|
+
advance =
|
|
3343
|
+
fonts.layout([{ ...face, text: `x${char}x` }], face, {}).width -
|
|
3344
|
+
fonts.layout([{ ...face, text: 'xx' }], face, {}).width;
|
|
3345
|
+
kept.set(key, advance);
|
|
3346
|
+
}
|
|
3347
|
+
return advance;
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
const ADVANCES = new WeakMap<FontsLike, Map<string, number>>();
|
|
3351
|
+
|
|
3352
|
+
function heldText(box: Box): string {
|
|
3353
|
+
if (box.style.whiteSpace !== 'pre' || !box.text.includes(' ')) {
|
|
3354
|
+
return box.text;
|
|
3355
|
+
}
|
|
3356
|
+
let held = HELD.get(box);
|
|
3357
|
+
if (held === undefined) {
|
|
3358
|
+
held = box.text.replace(/ /g, '\u00a0');
|
|
3359
|
+
HELD.set(box, held);
|
|
3360
|
+
}
|
|
3361
|
+
return held;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
const HELD = new WeakMap<Box, string>();
|
|
3365
|
+
|
|
3366
|
+
/**
|
|
3367
|
+
* Whether a line one layout made is shorter about its baseline than the
|
|
3368
|
+
* block's strut. Every line box starts with the strut, the block's font at
|
|
3369
|
+
* its line height (CSS 2.1 10.8.1), and a layout has none: it makes a line
|
|
3370
|
+
* as tall as the text on it, at the block's line height as a multiple of
|
|
3371
|
+
* each face's natural one. So a line of `<small>` text alone came out as
|
|
3372
|
+
* short as the small text, and overlapped what followed. A paragraph with
|
|
3373
|
+
* such a line is made a line at a time instead, where `finishLine` puts
|
|
3374
|
+
* the strut on every line; one whose lines all hold text of the block's
|
|
3375
|
+
* own face is kept as it was laid out.
|
|
3376
|
+
*/
|
|
3377
|
+
function shortOfStrut(
|
|
3378
|
+
lines: readonly LineBox[],
|
|
3379
|
+
strut: InlineDecoration | null,
|
|
3380
|
+
/** The first line's, where it holds more: a marker's room. */
|
|
3381
|
+
first: InlineDecoration | null = null,
|
|
3382
|
+
): boolean {
|
|
3383
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
3384
|
+
const line = lines[i];
|
|
3385
|
+
const holds = i === 0 && first ? first : strut;
|
|
3386
|
+
if (!holds) continue;
|
|
3387
|
+
if (
|
|
3388
|
+
line.baseline < holds.ascent - 0.5 ||
|
|
3389
|
+
line.height - line.baseline < holds.descent - 0.5
|
|
3390
|
+
) {
|
|
3391
|
+
return true;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
return false;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/** A strut with room besides it about the same baseline, or the strut
|
|
3398
|
+
* where there is none. */
|
|
3399
|
+
function withRoom(
|
|
3400
|
+
strut: InlineDecoration | null,
|
|
3401
|
+
room: InlineDecoration | undefined,
|
|
3402
|
+
): InlineDecoration | null {
|
|
3403
|
+
if (!room) return strut;
|
|
3404
|
+
if (!strut) return room;
|
|
3405
|
+
return {
|
|
3406
|
+
ascent: Math.max(strut.ascent, room.ascent),
|
|
3407
|
+
descent: Math.max(strut.descent, room.descent),
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
/** The share of the size a synthesized small capital is set at, rounded to
|
|
3412
|
+
* a whole pixel as Blink rounds it (`SimpleFontData`'s scaled font data):
|
|
3413
|
+
* CSS Fonts 4 (6.2) leaves the size to the user agent. */
|
|
3414
|
+
const SMALL_CAPS_SCALE = 0.7;
|
|
3415
|
+
|
|
3416
|
+
/**
|
|
3417
|
+
* Small capitals a face does not have, made of its capitals set smaller
|
|
3418
|
+
* (CSS Fonts 4, 6.2): where `font-variant-caps` asks for `smcp` — or
|
|
3419
|
+
* `c2sc` too, for `all-small-caps` — and the face does not answer it,
|
|
3420
|
+
* each letter that would be a small capital is its capital at 70% of the
|
|
3421
|
+
* size, in a run of its own: a lower-case letter, and for `all-small-caps`
|
|
3422
|
+
* the rest of the text too. Under `small-caps` what has no case — a space,
|
|
3423
|
+
* a digit, a mark of punctuation — keeps its size, as Blink keeps it. Only
|
|
3424
|
+
* a letter whose capital is one character is made one, so every offset
|
|
3425
|
+
* holds.
|
|
3426
|
+
*/
|
|
3427
|
+
function synthesizeSmallCaps(items: Item[], fonts: FontsLike): void {
|
|
3428
|
+
for (let i = 0; i < items.length; i++) {
|
|
3429
|
+
const item = items[i];
|
|
3430
|
+
if (item.kind !== 'text' || item.control) continue;
|
|
3431
|
+
// `font-variant-caps`'s, which is synthesized where the face has none;
|
|
3432
|
+
// not `font-feature-settings`', which asks for the feature as it is
|
|
3433
|
+
const caps = item.box.style.fontVariantCaps;
|
|
3434
|
+
const features = item.run.features;
|
|
3435
|
+
if (!caps.includes('smcp') || !features?.smcp) continue;
|
|
3436
|
+
const all = caps.includes('c2sc');
|
|
3437
|
+
if (hasSmallCaps(fonts, item.run, all)) continue;
|
|
3438
|
+
const pieces = smallCapsPieces(item, all, withoutCaps(features));
|
|
3439
|
+
if (!pieces) continue;
|
|
3440
|
+
items.splice(i, 1, ...pieces);
|
|
3441
|
+
i += pieces.length - 1;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
/** A text item cut where its letters turn to synthesized small capitals
|
|
3446
|
+
* and back, or null where none does. */
|
|
3447
|
+
function smallCapsPieces(
|
|
3448
|
+
item: Extract<Item, { kind: 'text' }>,
|
|
3449
|
+
all: boolean,
|
|
3450
|
+
features: Readonly<Record<string, number>> | null,
|
|
3451
|
+
): Extract<Item, { kind: 'text' }>[] | null {
|
|
3452
|
+
const text = item.run.text;
|
|
3453
|
+
const pieces: Extract<Item, { kind: 'text' }>[] = [];
|
|
3454
|
+
let from = 0;
|
|
3455
|
+
let small = false;
|
|
3456
|
+
let capitals = '';
|
|
3457
|
+
const cut = (to: number): void => {
|
|
3458
|
+
if (to === from) return;
|
|
3459
|
+
const run: TextRun = small
|
|
3460
|
+
? {
|
|
3461
|
+
...item.run,
|
|
3462
|
+
text: capitals,
|
|
3463
|
+
size: Math.round((item.run.size ?? 16) * SMALL_CAPS_SCALE),
|
|
3464
|
+
}
|
|
3465
|
+
: { ...item.run, text: text.slice(from, to) };
|
|
3466
|
+
if (small) {
|
|
3467
|
+
if (features) run.features = features;
|
|
3468
|
+
else delete run.features;
|
|
3469
|
+
}
|
|
3470
|
+
pieces.push({ ...item, run, start: item.start + from, length: to - from });
|
|
3471
|
+
from = to;
|
|
3472
|
+
capitals = '';
|
|
3473
|
+
};
|
|
3474
|
+
let any = false;
|
|
3475
|
+
for (let at = 0; at < text.length;) {
|
|
3476
|
+
const ch = String.fromCodePoint(text.codePointAt(at)!);
|
|
3477
|
+
const upper = ch.toUpperCase();
|
|
3478
|
+
// everything, for `all-small-caps`: its spaces are small too
|
|
3479
|
+
const becomes = (all || upper !== ch) && upper.length === ch.length;
|
|
3480
|
+
if (becomes !== small) {
|
|
3481
|
+
cut(at);
|
|
3482
|
+
small = becomes;
|
|
3483
|
+
}
|
|
3484
|
+
if (becomes) {
|
|
3485
|
+
capitals += upper;
|
|
3486
|
+
any = true;
|
|
3487
|
+
}
|
|
3488
|
+
at += ch.length;
|
|
3489
|
+
}
|
|
3490
|
+
cut(text.length);
|
|
3491
|
+
return any ? pieces : null;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
/** Features without the small capitals a synthesis stands in for, one
|
|
3495
|
+
* object for each set, as `featuresOf` keeps them. */
|
|
3496
|
+
function withoutCaps(
|
|
3497
|
+
features: Readonly<Record<string, number>>,
|
|
3498
|
+
): Readonly<Record<string, number>> | null {
|
|
3499
|
+
let kept = WITHOUT_CAPS.get(features);
|
|
3500
|
+
if (kept === undefined) {
|
|
3501
|
+
const out: Record<string, number> = {};
|
|
3502
|
+
for (const [tag, value] of Object.entries(features)) {
|
|
3503
|
+
if (tag !== 'smcp' && tag !== 'c2sc') out[tag] = value;
|
|
3504
|
+
}
|
|
3505
|
+
kept = Object.keys(out).length ? out : null;
|
|
3506
|
+
WITHOUT_CAPS.set(features, kept);
|
|
3507
|
+
}
|
|
3508
|
+
return kept;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
const WITHOUT_CAPS = new WeakMap<
|
|
3512
|
+
Readonly<Record<string, number>>,
|
|
3513
|
+
Readonly<Record<string, number>> | null
|
|
3514
|
+
>();
|
|
3515
|
+
|
|
3516
|
+
/**
|
|
3517
|
+
* Whether a run's face has small capitals of its own — and capitals to
|
|
3518
|
+
* small capitals, for `all-small-caps`: the alphabet set with the feature
|
|
3519
|
+
* and without it comes out another width where it does. Asked of the
|
|
3520
|
+
* engine's layout rather than of the font's tables, which a CoreText face
|
|
3521
|
+
* does not hand over. Kept per face.
|
|
3522
|
+
*/
|
|
3523
|
+
function hasSmallCaps(fonts: FontsLike, run: TextRun, all: boolean): boolean {
|
|
3524
|
+
let known = SMALL_CAPS.get(fonts);
|
|
3525
|
+
if (!known) SMALL_CAPS.set(fonts, (known = new Map()));
|
|
3526
|
+
const key = `${run.family}|${run.weight}|${run.style}|${all}`;
|
|
3527
|
+
let has = known.get(key);
|
|
3528
|
+
if (has === undefined) {
|
|
3529
|
+
const face = {
|
|
3530
|
+
family: run.family,
|
|
3531
|
+
weight: run.weight,
|
|
3532
|
+
style: run.style,
|
|
3533
|
+
size: 16,
|
|
3534
|
+
};
|
|
3535
|
+
const width = (text: string, features: Record<string, number> | null) =>
|
|
3536
|
+
fonts.layout(
|
|
3537
|
+
[{ text, ...face, ...(features ? { features } : null) }],
|
|
3538
|
+
face,
|
|
3539
|
+
{},
|
|
3540
|
+
).width;
|
|
3541
|
+
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
|
3542
|
+
has = Math.abs(width(lower, { smcp: 1 }) - width(lower, null)) > 0.01;
|
|
3543
|
+
if (has && all) {
|
|
3544
|
+
const upper = lower.toUpperCase();
|
|
3545
|
+
has = Math.abs(width(upper, { c2sc: 1 }) - width(upper, null)) > 0.01;
|
|
3546
|
+
}
|
|
3547
|
+
known.set(key, has);
|
|
3548
|
+
}
|
|
3549
|
+
return has;
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
const SMALL_CAPS = new WeakMap<FontsLike, Map<string, boolean>>();
|
|
3553
|
+
|
|
3554
|
+
/**
|
|
3555
|
+
* The OpenType features a style's text is shaped with: the `font-variant`
|
|
3556
|
+
* longhands' and `font-kerning`'s, then `font-feature-settings`, which has
|
|
3557
|
+
* the last word (CSS Fonts 3, 7.2). One object for each set of them, kept,
|
|
3558
|
+
* because a run's fields are compared by identity — a new object a pass
|
|
3559
|
+
* would find no layout the last pass made.
|
|
3560
|
+
*/
|
|
3561
|
+
export function featuresOf(
|
|
3562
|
+
style: ComputedStyle,
|
|
3563
|
+
): Readonly<Record<string, number>> | null {
|
|
3564
|
+
const {
|
|
3565
|
+
fontKerning: kerning,
|
|
3566
|
+
fontVariantLigatures: ligatures,
|
|
3567
|
+
fontVariantNumeric: numeric,
|
|
3568
|
+
fontVariantCaps: caps,
|
|
3569
|
+
fontVariantPosition: position,
|
|
3570
|
+
fontFeatureSettings: settings,
|
|
3571
|
+
} = style;
|
|
3572
|
+
if (!kerning && !ligatures && !numeric && !caps && !position && !settings) {
|
|
3573
|
+
return null;
|
|
3574
|
+
}
|
|
3575
|
+
const key = `${kerning}|${ligatures}|${numeric}|${caps}|${position}|${settings}`;
|
|
3576
|
+
let features = FEATURES.get(key);
|
|
3577
|
+
if (!features) {
|
|
3578
|
+
const out: Record<string, number> = {};
|
|
3579
|
+
for (const part of [
|
|
3580
|
+
kerning,
|
|
3581
|
+
ligatures,
|
|
3582
|
+
numeric,
|
|
3583
|
+
caps,
|
|
3584
|
+
position,
|
|
3585
|
+
settings,
|
|
3586
|
+
]) {
|
|
3587
|
+
if (!part) continue;
|
|
3588
|
+
for (const pair of part.split(',')) {
|
|
3589
|
+
const at = pair.indexOf('=');
|
|
3590
|
+
out[pair.slice(0, at)] = Number(pair.slice(at + 1));
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
features = Object.freeze(out);
|
|
3594
|
+
FEATURES.set(key, features);
|
|
3595
|
+
}
|
|
3596
|
+
return features;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
const FEATURES = new Map<string, Readonly<Record<string, number>>>();
|
|
3600
|
+
|
|
3601
|
+
/** A run of `text` in `style`: its face, its ink and its rules. Every
|
|
3602
|
+
* paint field of a run of the document's text is this function's, which
|
|
3603
|
+
* is what lets a pointer move re-ink a paragraph without laying it out
|
|
3604
|
+
* again (`HtmlViewNode._hoverInPlace`). */
|
|
3605
|
+
export function runFor(text: string, style: ComputedStyle): TextRun {
|
|
3606
|
+
const run: TextRun = {
|
|
668
3607
|
text,
|
|
669
3608
|
family: style.fontFamily,
|
|
670
3609
|
size: style.fontSize,
|
|
671
3610
|
weight: style.fontWeight,
|
|
672
3611
|
style: style.fontStyle === 'normal' ? 'normal' : 'italic',
|
|
673
|
-
color
|
|
3612
|
+
// the glyphs' fill, which `-webkit-text-fill-color` sets apart from
|
|
3613
|
+
// `color` — the decorations keep `color`
|
|
3614
|
+
color:
|
|
3615
|
+
style.textFillColor === null
|
|
3616
|
+
? style.color
|
|
3617
|
+
: inkColor(style.textFillColor, style.color),
|
|
3618
|
+
// set here, and made true later for an inline box's text (`collect`):
|
|
3619
|
+
// added to a run after it was made, it gave the runs of a document two
|
|
3620
|
+
// shapes where the layout cache compares them, and a pass over 600 KB
|
|
3621
|
+
// took 3 ms longer finding every paragraph's layout again
|
|
3622
|
+
shapeApart: false,
|
|
674
3623
|
};
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
3624
|
+
if (style.letterSpacing) run.letterSpacing = style.letterSpacing;
|
|
3625
|
+
const features = featuresOf(style);
|
|
3626
|
+
if (features) run.features = features;
|
|
3627
|
+
// Hidden text keeps its place on the line and draws nothing, rules
|
|
3628
|
+
// included, and the text of a visible element inside it is its own run
|
|
3629
|
+
// (CSS 2.1 11.2): drawn in no ink, it is laid out as it was
|
|
3630
|
+
if (style.visibility !== 'visible') {
|
|
3631
|
+
run.color = 'transparent';
|
|
3632
|
+
return run;
|
|
3633
|
+
}
|
|
3634
|
+
// A background is not the run's: the inline box it belongs to paints it,
|
|
3635
|
+
// behind every fragment of the box — nested elements' text included — and
|
|
3636
|
+
// out to its padding (`paintInlineBoxes`).
|
|
3637
|
+
// the element's own decorations and the ones propagated to it, in the
|
|
3638
|
+
// colours of the elements that set them (`decorate`)
|
|
3639
|
+
if (style.underline) {
|
|
3640
|
+
run.underline = style.underline;
|
|
689
3641
|
// CSS's five rule styles and SGR 4's five are the same set under two
|
|
690
3642
|
// names; richtext speaks SGR's, so `solid` is `single` and `wavy` is
|
|
691
3643
|
// `curly`. The other three are spelled identically.
|
|
692
3644
|
run.underlineStyle =
|
|
693
|
-
style.
|
|
3645
|
+
style.underlineStyle === 'wavy'
|
|
694
3646
|
? 'curly'
|
|
695
|
-
: style.
|
|
3647
|
+
: style.underlineStyle === 'solid'
|
|
696
3648
|
? 'single'
|
|
697
|
-
: style.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
3649
|
+
: style.underlineStyle;
|
|
3650
|
+
if (style.underlineOffset !== null) {
|
|
3651
|
+
run.underlineOffset = style.underlineOffset;
|
|
3652
|
+
}
|
|
3653
|
+
if (style.underlineThickness !== null) {
|
|
3654
|
+
run.underlineThickness = style.underlineThickness;
|
|
3655
|
+
}
|
|
703
3656
|
}
|
|
3657
|
+
if (style.lineThrough) run.strike = style.lineThrough;
|
|
704
3658
|
return run;
|
|
705
3659
|
}
|
|
706
3660
|
|
|
@@ -712,17 +3666,47 @@ function runFor(
|
|
|
712
3666
|
class SpanMap {
|
|
713
3667
|
private _laid: number[] = [];
|
|
714
3668
|
private _doc: number[] = [];
|
|
3669
|
+
private _boxes: (Box | null)[] = [];
|
|
3670
|
+
/** Where the laid-out text has a unit that is no text of the document's:
|
|
3671
|
+
* a spacer, a bidi control. */
|
|
3672
|
+
private _gaps: number[] | null = null;
|
|
715
3673
|
laidOut = 0;
|
|
716
3674
|
|
|
717
|
-
add(documentStart: number, length: number): void {
|
|
3675
|
+
add(documentStart: number, length: number, box: Box | null = null): void {
|
|
718
3676
|
this._laid.push(this.laidOut);
|
|
719
3677
|
this._doc.push(documentStart);
|
|
3678
|
+
this._boxes.push(box);
|
|
3679
|
+
if (!box) {
|
|
3680
|
+
this._gaps ??= [];
|
|
3681
|
+
for (let i = 0; i < length; i += 1) this._gaps.push(this.laidOut + i);
|
|
3682
|
+
}
|
|
720
3683
|
this.laidOut += length;
|
|
721
3684
|
}
|
|
722
3685
|
|
|
3686
|
+
/** Give a line's text the gaps in its part of the laid-out text, where
|
|
3687
|
+
* it has any. Set after the text is made rather than spread into it: a
|
|
3688
|
+
* spread in an object literal costs every line that has none. */
|
|
3689
|
+
giveGaps(text: LineText, start: number, end: number): void {
|
|
3690
|
+
if (!this._gaps) return;
|
|
3691
|
+
const gaps = this._gaps.filter((at) => at >= start && at < end);
|
|
3692
|
+
if (gaps.length) text.gaps = gaps;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
/** The text box whose text an offset in the laid-out text is. */
|
|
3696
|
+
boxAt(offset: number): Box | null {
|
|
3697
|
+
const i = this._entryAt(offset);
|
|
3698
|
+
return i < 0 ? null : this._boxes[i];
|
|
3699
|
+
}
|
|
3700
|
+
|
|
723
3701
|
documentAt(offset: number): number {
|
|
3702
|
+
const i = this._entryAt(offset);
|
|
3703
|
+
return i < 0 ? 0 : this._doc[i] + (offset - this._laid[i]);
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/** The entry an offset falls in, or -1 when there are none. */
|
|
3707
|
+
private _entryAt(offset: number): number {
|
|
724
3708
|
const laid = this._laid;
|
|
725
|
-
if (!laid.length) return
|
|
3709
|
+
if (!laid.length) return -1;
|
|
726
3710
|
let lo = 0;
|
|
727
3711
|
let hi = laid.length - 1;
|
|
728
3712
|
while (lo < hi) {
|
|
@@ -730,7 +3714,7 @@ class SpanMap {
|
|
|
730
3714
|
if (laid[mid] <= offset) lo = mid;
|
|
731
3715
|
else hi = mid - 1;
|
|
732
3716
|
}
|
|
733
|
-
return
|
|
3717
|
+
return lo;
|
|
734
3718
|
}
|
|
735
3719
|
}
|
|
736
3720
|
|
|
@@ -751,7 +3735,7 @@ function segmentFrom(items: Item[], index: number, offset: number): Segment {
|
|
|
751
3735
|
if (item.kind !== 'text') break;
|
|
752
3736
|
const text = skip > 0 ? item.run.text.slice(skip) : item.run.text;
|
|
753
3737
|
if (text) {
|
|
754
|
-
spans.add(item.
|
|
3738
|
+
spans.add(item.start + skip, text.length, item.control ? null : item.box);
|
|
755
3739
|
runs.push(skip > 0 ? { ...item.run, text } : item.run);
|
|
756
3740
|
}
|
|
757
3741
|
skip = 0;
|
|
@@ -759,6 +3743,10 @@ function segmentFrom(items: Item[], index: number, offset: number): Segment {
|
|
|
759
3743
|
return { runs, spans, nextIndex: i };
|
|
760
3744
|
}
|
|
761
3745
|
|
|
3746
|
+
/** A text a line may break after where its block wraps: one that ends in
|
|
3747
|
+
* white space, which a no-break space a `nowrap` element holds is not. */
|
|
3748
|
+
const BREAKS_AFTER = /[ \t\n]$/;
|
|
3749
|
+
|
|
762
3750
|
/** Move `(index, offset)` forward by `consumed` code units of text. */
|
|
763
3751
|
function advance(
|
|
764
3752
|
items: Item[],
|
|
@@ -784,8 +3772,9 @@ function advance(
|
|
|
784
3772
|
/**
|
|
785
3773
|
* One space's advance in a run's style, measured as the difference between
|
|
786
3774
|
* `x x` and `xx` — ntk strips a lone trailing space from a line, so it
|
|
787
|
-
* cannot be measured directly
|
|
788
|
-
*
|
|
3775
|
+
* cannot be measured directly — plus the run's letter spacing, which a
|
|
3776
|
+
* space takes as every other character does. Cached per font manager per
|
|
3777
|
+
* face; the measurement itself hits ntk's shaping memo.
|
|
789
3778
|
*/
|
|
790
3779
|
const SPACE_ADVANCE = new WeakMap<FontsLike, Map<string, number>>();
|
|
791
3780
|
|
|
@@ -795,9 +3784,10 @@ function spaceAdvance(fonts: FontsLike, run: TextRun): number {
|
|
|
795
3784
|
cache = new Map();
|
|
796
3785
|
SPACE_ADVANCE.set(fonts, cache);
|
|
797
3786
|
}
|
|
3787
|
+
const spacing = run.letterSpacing ?? 0;
|
|
798
3788
|
const key = `${run.family}|${run.size}|${run.weight}|${run.style}`;
|
|
799
3789
|
const hit = cache.get(key);
|
|
800
|
-
if (hit !== undefined) return hit;
|
|
3790
|
+
if (hit !== undefined) return hit + spacing;
|
|
801
3791
|
const style = {
|
|
802
3792
|
family: run.family,
|
|
803
3793
|
size: run.size,
|
|
@@ -809,7 +3799,124 @@ function spaceAdvance(fonts: FontsLike, run: TextRun): number {
|
|
|
809
3799
|
const advance = Math.max(0, measure('x x') - measure('xx'));
|
|
810
3800
|
if (cache.size > 64) cache.clear();
|
|
811
3801
|
cache.set(key, advance);
|
|
812
|
-
return advance;
|
|
3802
|
+
return advance + spacing;
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/** Whether the runs' joined text has a forced break just before a
|
|
3806
|
+
* code-unit offset — a line's end, which an engine counts the break in. */
|
|
3807
|
+
function breakBefore(runs: TextRun[], offset: number): boolean {
|
|
3808
|
+
let at = 0;
|
|
3809
|
+
for (const run of runs) {
|
|
3810
|
+
const next = at + run.text.length;
|
|
3811
|
+
if (offset - 1 < next) return run.text.charCodeAt(offset - 1 - at) === 10;
|
|
3812
|
+
at = next;
|
|
3813
|
+
}
|
|
3814
|
+
return false;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
/**
|
|
3818
|
+
* The white space a text engine strips from a line's end, as a pattern for
|
|
3819
|
+
* the end of a run: spaces and tabs, and the no-break space too where the
|
|
3820
|
+
* engine strips that — ntk to 8.12.9 and CoreText through @windowkit/appkit
|
|
3821
|
+
* 0.15.0 do, where CSS measures it (sidorares/ntk#395, windowkit/appkit#83).
|
|
3822
|
+
* Asked of the engine once per font manager, because what the line adds back
|
|
3823
|
+
* has to be what the engine took: added back after an engine that kept it, a
|
|
3824
|
+
* no-break space before an image counted twice.
|
|
3825
|
+
*/
|
|
3826
|
+
const HUNG = new WeakMap<FontsLike, RegExp>();
|
|
3827
|
+
|
|
3828
|
+
export function hungSpaces(fonts: FontsLike, run: TextRun): RegExp {
|
|
3829
|
+
let hung = HUNG.get(fonts);
|
|
3830
|
+
if (!hung) {
|
|
3831
|
+
const style = { family: run.family, size: run.size };
|
|
3832
|
+
const width = (text: string): number =>
|
|
3833
|
+
fonts.layout([{ ...style, text }], style, {}).lines[0]?.width ?? 0;
|
|
3834
|
+
hung = width('x\u00A0') > width('x') ? /[ \t]+$/ : /[ \t\u00A0]+$/;
|
|
3835
|
+
HUNG.set(fonts, hung);
|
|
3836
|
+
}
|
|
3837
|
+
return hung;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
/**
|
|
3841
|
+
* A clamped paragraph with an ellipsis at the end of its last line (CSS
|
|
3842
|
+
* Overflow 4, 4.2): its text up to where that line breaks in the room the
|
|
3843
|
+
* ellipsis leaves it — at a soft wrap opportunity, so the words that do
|
|
3844
|
+
* not fit beside the ellipsis go to the lines the clamp hides, and inside
|
|
3845
|
+
* a word only where the line has no other — and the ellipsis after it, in
|
|
3846
|
+
* the block's own style. The engine's own ellipsis cuts inside the last
|
|
3847
|
+
* word, as `text-overflow` does.
|
|
3848
|
+
*/
|
|
3849
|
+
function ellipsized(
|
|
3850
|
+
fonts: FontsLike,
|
|
3851
|
+
items: readonly Extract<Item, { kind: 'text' }>[],
|
|
3852
|
+
runs: TextRun[],
|
|
3853
|
+
base: Record<string, unknown>,
|
|
3854
|
+
options: Parameters<FontsLike['layout']>[2],
|
|
3855
|
+
layout: TextLayoutLike,
|
|
3856
|
+
width: number,
|
|
3857
|
+
style: ComputedStyle,
|
|
3858
|
+
): { layout: TextLayoutLike; runs: TextRun[]; spans: SpanMap } {
|
|
3859
|
+
const mark = runFor('\u2026', style);
|
|
3860
|
+
const room = width - fonts.layout([mark], base, {}).width;
|
|
3861
|
+
const last = layout.lines[layout.lines.length - 1];
|
|
3862
|
+
let end = last.end;
|
|
3863
|
+
if (last.width > room) {
|
|
3864
|
+
const probe = fonts.layout(sliceRuns(runs, last.start, last.end), base, {
|
|
3865
|
+
...options,
|
|
3866
|
+
maxWidth: Math.max(0, room),
|
|
3867
|
+
maxLines: 1,
|
|
3868
|
+
overflow: 'clip',
|
|
3869
|
+
overflowWrap: 'break-word',
|
|
3870
|
+
});
|
|
3871
|
+
end = last.start + (probe.lines[0]?.end ?? 0);
|
|
3872
|
+
}
|
|
3873
|
+
// the text to the break, less the white space the line ends in — an
|
|
3874
|
+
// empty line keeps the break before it, and is the ellipsis alone
|
|
3875
|
+
const line = sliceRuns(runs, last.start, end);
|
|
3876
|
+
let trailing = 0;
|
|
3877
|
+
for (let i = line.length - 1; i >= 0; i -= 1) {
|
|
3878
|
+
const text = line[i].text;
|
|
3879
|
+
const trimmed = text.replace(/[ \t\n\r\f]+$/, '').length;
|
|
3880
|
+
trailing += text.length - trimmed;
|
|
3881
|
+
if (trimmed) break;
|
|
3882
|
+
}
|
|
3883
|
+
const kept = sliceRuns(runs, 0, end - trailing);
|
|
3884
|
+
// and the spans to match, the ellipsis no text of the document's
|
|
3885
|
+
const spans = new SpanMap();
|
|
3886
|
+
let at = items.length ? items[0].start : 0;
|
|
3887
|
+
let left = 0;
|
|
3888
|
+
for (const run of kept) left += run.text.length;
|
|
3889
|
+
for (const item of items) {
|
|
3890
|
+
if (left <= 0) break;
|
|
3891
|
+
const length = Math.min(item.run.text.length, left);
|
|
3892
|
+
spans.add(item.start, length, item.control ? null : item.box);
|
|
3893
|
+
left -= length;
|
|
3894
|
+
at = item.start + length;
|
|
3895
|
+
}
|
|
3896
|
+
spans.add(at, 1, null);
|
|
3897
|
+
kept.push(mark);
|
|
3898
|
+
return { layout: fonts.layout(kept, base, options), runs: kept, spans };
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
/** The runs' text from one code-unit offset into their joined text to
|
|
3902
|
+
* another, each piece in its own run's style. */
|
|
3903
|
+
function sliceRuns(
|
|
3904
|
+
runs: readonly TextRun[],
|
|
3905
|
+
from: number,
|
|
3906
|
+
to: number,
|
|
3907
|
+
): TextRun[] {
|
|
3908
|
+
const out: TextRun[] = [];
|
|
3909
|
+
let at = 0;
|
|
3910
|
+
for (const run of runs) {
|
|
3911
|
+
const next = at + run.text.length;
|
|
3912
|
+
if (next > from && at < to) {
|
|
3913
|
+
const text = run.text.slice(Math.max(0, from - at), to - at);
|
|
3914
|
+
out.push(text === run.text ? run : { ...run, text });
|
|
3915
|
+
}
|
|
3916
|
+
at = next;
|
|
3917
|
+
if (at >= to) break;
|
|
3918
|
+
}
|
|
3919
|
+
return out;
|
|
813
3920
|
}
|
|
814
3921
|
|
|
815
3922
|
/** Whether anything but whitespace lies past a code-unit offset into the
|
|
@@ -819,7 +3926,10 @@ function inkBeyond(runs: TextRun[], offset: number): boolean {
|
|
|
819
3926
|
let at = 0;
|
|
820
3927
|
for (const run of runs) {
|
|
821
3928
|
const next = at + run.text.length;
|
|
822
|
-
if (
|
|
3929
|
+
if (
|
|
3930
|
+
next > offset &&
|
|
3931
|
+
/[^ \t\n\r\f]/.test(run.text.slice(Math.max(0, offset - at)))
|
|
3932
|
+
) {
|
|
823
3933
|
return true;
|
|
824
3934
|
}
|
|
825
3935
|
at = next;
|
|
@@ -833,17 +3943,481 @@ function wraps(style: ComputedStyle): boolean {
|
|
|
833
3943
|
return style.whiteSpace !== 'nowrap' && style.whiteSpace !== 'pre';
|
|
834
3944
|
}
|
|
835
3945
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
3946
|
+
/** How much of a line's unused room goes before it: 0 for a line set flush
|
|
3947
|
+
* left, 1 flush right, ½ centred. `start` and `end` follow the direction;
|
|
3948
|
+
* `justify` is set as `start`, as `alignFor` sets it. */
|
|
3949
|
+
/** How far into the room a line an alignment sets goes: 0 at the left, 1
|
|
3950
|
+
* at the right; `justify` at the start, since a line not justified is
|
|
3951
|
+
* set there. */
|
|
3952
|
+
function alignShift(
|
|
3953
|
+
align: ComputedStyle['textAlign'] | ComputedStyle['textAlignLast'],
|
|
3954
|
+
rtl: boolean,
|
|
3955
|
+
): number {
|
|
3956
|
+
switch (align) {
|
|
3957
|
+
case 'center':
|
|
3958
|
+
return 0.5;
|
|
3959
|
+
case 'right':
|
|
3960
|
+
return 1;
|
|
3961
|
+
case 'left':
|
|
3962
|
+
return 0;
|
|
3963
|
+
case 'end':
|
|
3964
|
+
return rtl ? 0 : 1;
|
|
3965
|
+
default:
|
|
3966
|
+
return rtl ? 1 : 0;
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
/** How far a line of a layout moves to where it belongs in its box. */
|
|
3971
|
+
type LinePlacer = (line: { x: number; width: number }) => number;
|
|
3972
|
+
|
|
3973
|
+
/** The blocks whose lines hold text a box's background shows through. */
|
|
3974
|
+
export const CLIPPED_TEXT = new WeakSet<Box>();
|
|
3975
|
+
|
|
3976
|
+
/** How each of those blocks' layouts was made, to make it again. */
|
|
3977
|
+
const RECORDED = new WeakMap<
|
|
3978
|
+
object,
|
|
3979
|
+
{
|
|
3980
|
+
fonts: FontsLike;
|
|
3981
|
+
content: TextRun[];
|
|
3982
|
+
style: Record<string, unknown>;
|
|
3983
|
+
options: Parameters<FontsLike['layout']>[2];
|
|
3984
|
+
}
|
|
3985
|
+
>();
|
|
3986
|
+
const RECORDERS = new WeakMap<FontsLike, FontsLike>();
|
|
3987
|
+
const INKLESS = new WeakMap<object, TextLayoutLike | null>();
|
|
3988
|
+
|
|
3989
|
+
/** The fonts, noting how each layout they make was made. One recorder a
|
|
3990
|
+
* fonts object, so what is kept per fonts object is kept for it too. */
|
|
3991
|
+
function recording(fonts: FontsLike): FontsLike {
|
|
3992
|
+
let recorder = RECORDERS.get(fonts);
|
|
3993
|
+
if (!recorder) {
|
|
3994
|
+
recorder = {
|
|
3995
|
+
layout(content, style, options) {
|
|
3996
|
+
const layout = fonts.layout(content, style, options);
|
|
3997
|
+
RECORDED.set(layout, { fonts, content, style, options });
|
|
3998
|
+
return layout;
|
|
3999
|
+
},
|
|
4000
|
+
match: (family, style) => fonts.match(family, style),
|
|
4001
|
+
};
|
|
4002
|
+
RECORDERS.set(fonts, recorder);
|
|
4003
|
+
}
|
|
4004
|
+
return recorder;
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* A layout of the same text with no ink of its own, which an engine draws
|
|
4009
|
+
* in the context's fill — a gradient's, for text a background shows
|
|
4010
|
+
* through; the same runs, so the same glyphs where the first put them.
|
|
4011
|
+
* Null for a layout that was not recorded.
|
|
4012
|
+
*/
|
|
4013
|
+
export function inklessLayout(layout: object): TextLayoutLike | null {
|
|
4014
|
+
let inkless = INKLESS.get(layout);
|
|
4015
|
+
if (inkless !== undefined) return inkless;
|
|
4016
|
+
const made = RECORDED.get(layout);
|
|
4017
|
+
inkless = made
|
|
4018
|
+
? made.fonts.layout(
|
|
4019
|
+
made.content.map((run) => ({ ...run, color: undefined })),
|
|
4020
|
+
{ ...made.style, color: undefined },
|
|
4021
|
+
made.options,
|
|
4022
|
+
)
|
|
4023
|
+
: null;
|
|
4024
|
+
INKLESS.set(layout, inkless);
|
|
4025
|
+
return inkless;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
/** Whether any of a block's text is text a background shows through. */
|
|
4029
|
+
function clipsText(block: Box, items: Item[]): boolean {
|
|
4030
|
+
for (const item of items) {
|
|
4031
|
+
if (item.kind === 'text' && !item.control && clipBoxOf(item.box, block)) {
|
|
4032
|
+
return true;
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
return false;
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
/** The box whose background a text box's glyphs show: the nearest element
|
|
4039
|
+
* box from it up to the block its lines are in with `background-clip:
|
|
4040
|
+
* text`, or null. */
|
|
4041
|
+
export function clipBoxOf(box: Box | null, block: Box): Box | null {
|
|
4042
|
+
for (let at = box; at; at = at.parent) {
|
|
4043
|
+
if (at.kind !== 'text' && at.style.backgroundClipText) return at;
|
|
4044
|
+
if (at === block) break;
|
|
4045
|
+
}
|
|
4046
|
+
return null;
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
/** The most lines `text-wrap: balance` evens out, as in Chrome: a heading,
|
|
4050
|
+
* a caption, a pull quote — not a paragraph, whose last line is its own. */
|
|
4051
|
+
const BALANCE_LINES = 6;
|
|
4052
|
+
|
|
4053
|
+
/**
|
|
4054
|
+
* The narrowest width a paragraph's text breaks into no more lines than
|
|
4055
|
+
* `layout` has (CSS Text 4, 6.2), found by halving the width between the
|
|
4056
|
+
* least that could hold them — their widths over their number — and the
|
|
4057
|
+
* width it was laid out at. The engine breaks the lines, so each step
|
|
4058
|
+
* asks it; eight steps come to within a pixel of the answer for a heading
|
|
4059
|
+
* across a page.
|
|
4060
|
+
*/
|
|
4061
|
+
function balancedWidth(
|
|
4062
|
+
fonts: FontsLike,
|
|
4063
|
+
runs: TextRun[],
|
|
4064
|
+
base: Record<string, unknown>,
|
|
4065
|
+
options: Parameters<FontsLike['layout']>[2],
|
|
4066
|
+
width: number,
|
|
4067
|
+
layout: TextLayoutLike,
|
|
4068
|
+
): number {
|
|
4069
|
+
const count = layout.lines.length;
|
|
4070
|
+
let total = 0;
|
|
4071
|
+
for (const line of layout.lines) total += line.width;
|
|
4072
|
+
let lo = total / count;
|
|
4073
|
+
let hi = width;
|
|
4074
|
+
for (let step = 0; step < 8 && hi - lo > 1; step += 1) {
|
|
4075
|
+
const mid = (lo + hi) / 2;
|
|
4076
|
+
const probe = fonts.layout(runs, base, { ...options, maxWidth: mid });
|
|
4077
|
+
if (probe.lines.length <= count) hi = mid;
|
|
4078
|
+
else lo = mid;
|
|
4079
|
+
}
|
|
4080
|
+
return Math.ceil(hi);
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/**
|
|
4084
|
+
* Where `text-align` puts the lines of a layout that was given no width to
|
|
4085
|
+
* align them in — text that does not wrap, which has no `maxWidth` — as how
|
|
4086
|
+
* far each moves: the engine aligns those lines within the widest of them,
|
|
4087
|
+
* which for a single line is no alignment at all, so a centred `<td
|
|
4088
|
+
* nowrap>` or a `white-space: nowrap` button label was set flush left. A
|
|
4089
|
+
* line too long for its box is set at its start, overflowing its end (CSS
|
|
4090
|
+
* Text 3, 7.1), which is its left in a right-to-left paragraph, as in
|
|
4091
|
+
* Blink. Null where every line is already where it belongs: flush left in
|
|
4092
|
+
* a left-to-right paragraph, or measured for a width it has not been given.
|
|
4093
|
+
*/
|
|
4094
|
+
/** How the lines of a layout made with no width to fill are placed in
|
|
4095
|
+
* the room: each is its text's last or a forced break's, and takes
|
|
4096
|
+
* `text-align-last`, but where `last` is false — a balanced paragraph's,
|
|
4097
|
+
* laid out narrower than its room. */
|
|
4098
|
+
function unwrappedPlacer(
|
|
4099
|
+
style: ComputedStyle,
|
|
4100
|
+
width: number,
|
|
4101
|
+
last = true,
|
|
4102
|
+
): LinePlacer | null {
|
|
4103
|
+
const rtl = style.direction === 'rtl';
|
|
4104
|
+
const shift = alignShift(last ? lastAlignOf(style) : style.textAlign, rtl);
|
|
4105
|
+
if ((shift === 0 && !rtl) || !Number.isFinite(width)) return null;
|
|
4106
|
+
return (line) => {
|
|
4107
|
+
const free = width - line.width;
|
|
4108
|
+
return (free >= 0 ? free * shift : rtl ? free : 0) - line.x;
|
|
4109
|
+
};
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
/**
|
|
4113
|
+
* The spacing a space is given to be laid out as one that may be spaced
|
|
4114
|
+
* apart, too little to move a glyph. It is not nothing, because neither
|
|
4115
|
+
* engine measures a space that is a spaced run of its own as it measures
|
|
4116
|
+
* the same space inside its run: ntk shapes each run apart, so the kerning
|
|
4117
|
+
* pair a space made with the letter beside it is gone (Arial's `A` and
|
|
4118
|
+
* `T` have them), and CoreText spaces a glyph with its kerning attribute,
|
|
4119
|
+
* which takes the place of the font's pairs. A line justified from a
|
|
4120
|
+
* measure of its spaces as they were came out wider than its box and
|
|
4121
|
+
* broke a word early: on macOS, in Helvetica, half of a paragraph did.
|
|
4122
|
+
*/
|
|
4123
|
+
const HAIR = 1e-6;
|
|
4124
|
+
|
|
4125
|
+
/** The runs with each space a run of its own, spaced a hair apart. */
|
|
4126
|
+
function spacedApart(runs: TextRun[]): TextRun[] {
|
|
4127
|
+
const out: TextRun[] = [];
|
|
4128
|
+
for (const run of runs) {
|
|
4129
|
+
const text = run.text;
|
|
4130
|
+
let piece = 0;
|
|
4131
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
4132
|
+
const c = text.charCodeAt(at);
|
|
4133
|
+
if (c !== 0x20 && c !== 0xa0) continue;
|
|
4134
|
+
if (at > piece) out.push({ ...run, text: text.slice(piece, at) });
|
|
4135
|
+
out.push({
|
|
4136
|
+
...run,
|
|
4137
|
+
text: text[at],
|
|
4138
|
+
letterSpacing: (run.letterSpacing ?? 0) + HAIR,
|
|
4139
|
+
});
|
|
4140
|
+
piece = at + 1;
|
|
4141
|
+
}
|
|
4142
|
+
if (piece === 0) out.push(run);
|
|
4143
|
+
else if (piece < text.length) out.push({ ...run, text: text.slice(piece) });
|
|
4144
|
+
}
|
|
4145
|
+
return out;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
/**
|
|
4149
|
+
* `text-align: justify` (CSS Text 3, 7.4), as neither engine has it: the
|
|
4150
|
+
* paragraph's runs again, with each space inside a line that is to be
|
|
4151
|
+
* justified widened by its share of what the line leaves of `width` — the
|
|
4152
|
+
* `letter-spacing` a `word-spacing` is drawn with — so the same breaks fill
|
|
4153
|
+
* their lines. A line that is the paragraph's last, or that a forced break
|
|
4154
|
+
* ends, is not justified, nor one with no space inside it; the spaces a
|
|
4155
|
+
* line ends on hang, and take no share. `skip` is the offsets of spaces
|
|
4156
|
+
* that are no word separators — an inline box's edge laid out as a spacer
|
|
4157
|
+
* (`layoutSpaced`) — which take no share either. Null where no line is
|
|
4158
|
+
* justified.
|
|
4159
|
+
*/
|
|
4160
|
+
function justifiedRuns(
|
|
4161
|
+
runs: TextRun[],
|
|
4162
|
+
layout: TextLayoutLike,
|
|
4163
|
+
width: number,
|
|
4164
|
+
rest = true,
|
|
4165
|
+
last = false,
|
|
4166
|
+
skip?: ReadonlySet<number>,
|
|
4167
|
+
): TextRun[] | null {
|
|
4168
|
+
const text = runs.map((run) => run.text).join('');
|
|
4169
|
+
// each space's extra, by its offset in the paragraph
|
|
4170
|
+
const extra = new Map<number, number>();
|
|
4171
|
+
const lines = layout.lines;
|
|
4172
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
4173
|
+
const line = lines[i];
|
|
4174
|
+
let end = line.end;
|
|
4175
|
+
// the paragraph's last line, or one a forced break ends, is
|
|
4176
|
+
// `text-align-last`'s to justify
|
|
4177
|
+
const ends =
|
|
4178
|
+
i === lines.length - 1 || text[end - 1] === '\n' || text[end] === '\n';
|
|
4179
|
+
if (!(ends ? last : rest)) continue;
|
|
4180
|
+
while (
|
|
4181
|
+
end > line.start &&
|
|
4182
|
+
(text[end - 1] === ' ' ||
|
|
4183
|
+
text[end - 1] === '\u00a0' ||
|
|
4184
|
+
!!skip?.has(end - 1))
|
|
4185
|
+
)
|
|
4186
|
+
end -= 1;
|
|
4187
|
+
const spaces: number[] = [];
|
|
4188
|
+
for (let at = line.start; at < end; at += 1) {
|
|
4189
|
+
if ((text[at] === ' ' || text[at] === '\u00a0') && !skip?.has(at)) {
|
|
4190
|
+
spaces.push(at);
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
// a hair short, so that the engine breaks where it did
|
|
4194
|
+
const slack = width - line.width - 0.01;
|
|
4195
|
+
if (!spaces.length || !(slack > 0)) continue;
|
|
4196
|
+
for (const at of spaces) extra.set(at, slack / spaces.length);
|
|
4197
|
+
}
|
|
4198
|
+
return extra.size ? widenedAt(runs, extra) : null;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
/** The runs with the character at each offset of `extra` a run of its
|
|
4202
|
+
* own, spaced that much more. */
|
|
4203
|
+
function widenedAt(runs: TextRun[], extra: Map<number, number>): TextRun[] {
|
|
4204
|
+
const text = runs.map((run) => run.text).join('');
|
|
4205
|
+
const out: TextRun[] = [];
|
|
4206
|
+
let from = 0;
|
|
4207
|
+
for (const run of runs) {
|
|
4208
|
+
const end = from + run.text.length;
|
|
4209
|
+
let piece = from;
|
|
4210
|
+
for (let at = from; at < end; at += 1) {
|
|
4211
|
+
const add = extra.get(at);
|
|
4212
|
+
if (add === undefined) continue;
|
|
4213
|
+
if (at > piece) out.push({ ...run, text: text.slice(piece, at) });
|
|
4214
|
+
out.push({
|
|
4215
|
+
...run,
|
|
4216
|
+
text: text[at],
|
|
4217
|
+
letterSpacing: (run.letterSpacing ?? 0) + add,
|
|
4218
|
+
});
|
|
4219
|
+
piece = at + 1;
|
|
4220
|
+
}
|
|
4221
|
+
if (end > piece) out.push({ ...run, text: text.slice(piece, end) });
|
|
4222
|
+
from = end;
|
|
4223
|
+
}
|
|
4224
|
+
return out;
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
/** A word separator, as justification spaces them out (CSS Text 3, 7.4):
|
|
4228
|
+
* a space, or a no-break one. */
|
|
4229
|
+
function isSeparator(code: number): boolean {
|
|
4230
|
+
return code === 0x20 || code === 0xa0;
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
/** What `finishLine` lays a justified line's text out again with. */
|
|
4234
|
+
interface LineJustify {
|
|
4235
|
+
fonts: FontsLike;
|
|
4236
|
+
base: Record<string, unknown>;
|
|
4237
|
+
options: Parameters<FontsLike['layout']>[2];
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
/**
|
|
4241
|
+
* A line made a piece at a time — beside a float, around an inline-block,
|
|
4242
|
+
* between an inline box's edges — justified where it is (CSS Text 3, 7.4):
|
|
4243
|
+
* the room it leaves, `free`, shared out equally among its word
|
|
4244
|
+
* separators, as `justifiedRuns` shares out a paragraph's. A space inside a
|
|
4245
|
+
* piece of text takes its share as letter spacing, the piece laid out again
|
|
4246
|
+
* with it; a space a piece ends on, with something after it on the line,
|
|
4247
|
+
* takes it as room before what follows. The spaces the line ends on hang
|
|
4248
|
+
* (7.3), whatever inline box closes after them, and take none. Whatever
|
|
4249
|
+
* follows a share on the line moves over by it. How much wider the line
|
|
4250
|
+
* is.
|
|
4251
|
+
*/
|
|
4252
|
+
function justifyLine(open: OpenLine, free: number, how: LineJustify): number {
|
|
4253
|
+
const order = open.order;
|
|
4254
|
+
let lastContent = -1;
|
|
4255
|
+
for (let i = order.length - 1; i >= 0; i -= 1) {
|
|
4256
|
+
if (order[i].kind !== 'edge') {
|
|
4257
|
+
lastContent = i;
|
|
4258
|
+
break;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
const plans = new Map<
|
|
4262
|
+
LineText,
|
|
4263
|
+
{ runs: TextRun[]; inside: number[]; after: number }
|
|
4264
|
+
>();
|
|
4265
|
+
let count = 0;
|
|
4266
|
+
for (let i = 0; i < order.length; i += 1) {
|
|
4267
|
+
const placed = order[i];
|
|
4268
|
+
if (placed.kind !== 'text') continue;
|
|
4269
|
+
const text = placed.item;
|
|
4270
|
+
// the runs it was laid out from, where they are still what is kept
|
|
4271
|
+
// (a selection keeps its offsets in their place)
|
|
4272
|
+
const kept = LAYOUT_RUNS.get(text.layout);
|
|
4273
|
+
const runs =
|
|
4274
|
+
kept?.length && typeof kept[0] !== 'number' ? (kept as TextRun[]) : null;
|
|
4275
|
+
const line = text.layout.lines[text.layoutLine];
|
|
4276
|
+
if (!runs || !line) continue;
|
|
4277
|
+
const all = runs.map((run) => run.text).join('');
|
|
4278
|
+
let end = line.end;
|
|
4279
|
+
while (end > line.start && isSeparator(all.charCodeAt(end - 1))) end -= 1;
|
|
4280
|
+
const inside: number[] = [];
|
|
4281
|
+
for (let at = line.start; at < end; at += 1) {
|
|
4282
|
+
if (isSeparator(all.charCodeAt(at))) inside.push(at);
|
|
4283
|
+
}
|
|
4284
|
+
// the spaces it ends on, before what follows it on the line
|
|
4285
|
+
let after = 0;
|
|
4286
|
+
if (i < lastContent) {
|
|
4287
|
+
for (let at = end; at < all.length; at += 1) {
|
|
4288
|
+
if (!isSeparator(all.charCodeAt(at))) break;
|
|
4289
|
+
after += 1;
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
count += inside.length + after;
|
|
4293
|
+
plans.set(text, { runs, inside, after });
|
|
4294
|
+
}
|
|
4295
|
+
if (!count) return 0;
|
|
4296
|
+
const share = free / count;
|
|
4297
|
+
let moved = 0;
|
|
4298
|
+
for (const placed of order) {
|
|
4299
|
+
if (placed.kind !== 'text') {
|
|
4300
|
+
placed.item.x += moved;
|
|
4301
|
+
continue;
|
|
4302
|
+
}
|
|
4303
|
+
const text = placed.item;
|
|
4304
|
+
text.drawX += moved;
|
|
4305
|
+
const plan = plans.get(text);
|
|
4306
|
+
if (!plan) continue;
|
|
4307
|
+
if (plan.inside.length) {
|
|
4308
|
+
const line = text.layout.lines[text.layoutLine];
|
|
4309
|
+
const widened = widenedAt(
|
|
4310
|
+
plan.runs,
|
|
4311
|
+
new Map(plan.inside.map((at) => [at, share])),
|
|
4312
|
+
);
|
|
4313
|
+
// as wide as the piece is to be, and the piece alone: the text after
|
|
4314
|
+
// it did not fit beside it before it was any wider
|
|
4315
|
+
const layout = how.fonts.layout(widened, how.base, {
|
|
4316
|
+
...how.options,
|
|
4317
|
+
maxWidth: line.width + plan.inside.length * share + 0.5,
|
|
4318
|
+
maxLines: 1,
|
|
4319
|
+
});
|
|
4320
|
+
const first = layout.lines[0];
|
|
4321
|
+
if (first && first.start === line.start && first.end === line.end) {
|
|
4322
|
+
LAYOUT_RUNS.set(layout, widened);
|
|
4323
|
+
text.layout = layout;
|
|
4324
|
+
text.layoutLine = 0;
|
|
4325
|
+
moved += first.width - line.width;
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
moved += plan.after * share;
|
|
4329
|
+
}
|
|
4330
|
+
return moved;
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
/** Which of a paragraph's lines are justified (CSS Text 3, 7.4): those
|
|
4334
|
+
* its end or a forced break does not end, where `text-align` is
|
|
4335
|
+
* `justify`, and those it does, where `text-align-last` is — neither
|
|
4336
|
+
* where the text does not wrap, nor under `text-justify: none`. */
|
|
4337
|
+
function justification(style: ComputedStyle): { rest: boolean; last: boolean } {
|
|
4338
|
+
const on = wraps(style) && style.textJustify !== 'none';
|
|
4339
|
+
return {
|
|
4340
|
+
rest: on && style.textAlign === 'justify',
|
|
4341
|
+
last: on && lastAlignOf(style) === 'justify',
|
|
4342
|
+
};
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
/**
|
|
4346
|
+
* A layout of `runs` whose justified lines fill `width`: measured as it
|
|
4347
|
+
* will be drawn, its spaces spaced apart (`HAIR`), then laid out again with
|
|
4348
|
+
* each space inside a justified line widened by its share
|
|
4349
|
+
* (`justifiedRuns`), which keeps the lines where they broke. The layout
|
|
4350
|
+
* that is given where no line is to be justified. Every path that lays a
|
|
4351
|
+
* paragraph out as one layout justifies through this, so that a paragraph
|
|
4352
|
+
* is justified whichever it took.
|
|
4353
|
+
*/
|
|
4354
|
+
function justifiedLayout(
|
|
4355
|
+
fonts: FontsLike,
|
|
4356
|
+
runs: TextRun[],
|
|
4357
|
+
base: Record<string, unknown>,
|
|
4358
|
+
options: Parameters<FontsLike['layout']>[2],
|
|
4359
|
+
layout: TextLayoutLike,
|
|
4360
|
+
width: number,
|
|
4361
|
+
{ rest, last }: { rest: boolean; last: boolean },
|
|
4362
|
+
skip?: ReadonlySet<number>,
|
|
4363
|
+
): { layout: TextLayoutLike; runs: TextRun[] } {
|
|
4364
|
+
if (
|
|
4365
|
+
!Number.isFinite(width) ||
|
|
4366
|
+
!((rest && layout.lines.length > 1) || (last && layout.lines.length > 0))
|
|
4367
|
+
) {
|
|
4368
|
+
return { layout, runs };
|
|
4369
|
+
}
|
|
4370
|
+
const spaced = spacedApart(runs);
|
|
4371
|
+
const measured = fonts.layout(spaced, base, options);
|
|
4372
|
+
const justified = justifiedRuns(spaced, measured, width, rest, last, skip);
|
|
4373
|
+
const out = justified ? fonts.layout(justified, base, options) : measured;
|
|
4374
|
+
const outRuns = justified ?? spaced;
|
|
4375
|
+
LAYOUT_RUNS.set(out, outRuns);
|
|
4376
|
+
return { layout: out, runs: outRuns };
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
/** An alignment as the text engine takes it. It has no justification:
|
|
4380
|
+
* `start` is closer than a silent left on an RTL paragraph, and closer
|
|
4381
|
+
* than a ragged-right lie about what was drawn — justified lines are
|
|
4382
|
+
* spaced to fill their width before they get here (`justifiedRuns`). */
|
|
4383
|
+
function engineAlign(
|
|
4384
|
+
align: ComputedStyle['textAlign'] | ComputedStyle['textAlignLast'],
|
|
4385
|
+
): string {
|
|
4386
|
+
return align === 'justify' || align === 'auto' ? 'start' : align;
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
/** How the lines the text or a forced break ends are aligned (CSS Text 3,
|
|
4390
|
+
* 7.2): `text-align-last`, or where that is `auto`, `text-align`, but
|
|
4391
|
+
* `justify`'s, which leaves them at the start. */
|
|
4392
|
+
function lastAlignOf(
|
|
4393
|
+
style: ComputedStyle,
|
|
4394
|
+
): Exclude<ComputedStyle['textAlignLast'], 'auto'> {
|
|
4395
|
+
const last = style.textAlignLast;
|
|
4396
|
+
if (last !== 'auto') return last;
|
|
839
4397
|
return style.textAlign === 'justify' ? 'start' : style.textAlign;
|
|
840
4398
|
}
|
|
841
4399
|
|
|
4400
|
+
/**
|
|
4401
|
+
* The `text-indent` a block's first line takes: none where that line is
|
|
4402
|
+
* not its element's first formatted line (CSS 2.1 16.1). An anonymous
|
|
4403
|
+
* block's is only where the block is its parent's first child: the text
|
|
4404
|
+
* after a `<div>` in a `<span>`, or after a paragraph in a `<div>`, starts
|
|
4405
|
+
* a line of no indent.
|
|
4406
|
+
*/
|
|
4407
|
+
function blockIndent(block: Box, width: number): number {
|
|
4408
|
+
if (!block.el && block.parent && block.parent.children[0] !== block) {
|
|
4409
|
+
return 0;
|
|
4410
|
+
}
|
|
4411
|
+
return indentOf(block.style, width);
|
|
4412
|
+
}
|
|
4413
|
+
|
|
842
4414
|
function indentOf(style: ComputedStyle, width: number): number {
|
|
843
4415
|
const indent = style.textIndent;
|
|
844
4416
|
if (typeof indent === 'number') return indent;
|
|
845
4417
|
if (indent === 'auto') return 0;
|
|
846
|
-
|
|
4418
|
+
// of zero where the width is not known yet, as when measuring the
|
|
4419
|
+
// content's width: a `calc()` keeps its pixels (CSS Sizing 3 5.2.1)
|
|
4420
|
+
return resolve(indent, Number.isFinite(width) ? width : 0);
|
|
847
4421
|
}
|
|
848
4422
|
|
|
849
4423
|
/**
|
|
@@ -854,37 +4428,458 @@ function indentOf(style: ComputedStyle, width: number): number {
|
|
|
854
4428
|
* rather than passing the CSS number through is the difference between
|
|
855
4429
|
* `line-height: 1.5` meaning 1.5 × 16px and it meaning 1.5 × 19px, which is
|
|
856
4430
|
* a visibly looser document than the author asked for.
|
|
4431
|
+
*
|
|
4432
|
+
* `line-height: 0` is legal and means what it says — lines of no height,
|
|
4433
|
+
* the glyphs overflowing evenly — and ntk's half-leading does exactly that
|
|
4434
|
+
* with a multiplier of 0. The floor is a hair above it rather than the 0.1
|
|
4435
|
+
* it was, which set such lines a tenth of a line apart: the Cocoa engine
|
|
4436
|
+
* takes a zero multiple for none at all.
|
|
857
4437
|
*/
|
|
858
|
-
function lineHeightMultiplier(
|
|
4438
|
+
export function lineHeightMultiplier(
|
|
4439
|
+
fonts: FontsLike,
|
|
4440
|
+
style: ComputedStyle,
|
|
4441
|
+
): number {
|
|
859
4442
|
if (style.lineHeight === 'normal') return 1;
|
|
860
4443
|
const target = style.lineHeightIsLength
|
|
861
4444
|
? (style.lineHeight as number)
|
|
862
4445
|
: (style.lineHeight as number) * style.fontSize;
|
|
863
4446
|
const natural = naturalLineHeight(fonts, style);
|
|
864
4447
|
if (!natural) return 1;
|
|
865
|
-
return Math.max(
|
|
4448
|
+
return Math.max(1e-4, target / natural);
|
|
866
4449
|
}
|
|
867
4450
|
|
|
4451
|
+
/**
|
|
4452
|
+
* The font's natural line height in a style, which CSS's number form is
|
|
4453
|
+
* converted against. Kept per font manager per style, like a space's
|
|
4454
|
+
* advance: a pass asks once a paragraph — 8,800 times at 600 KB — and on
|
|
4455
|
+
* CoreText every answer was a call to the native side. Found by the style
|
|
4456
|
+
* object first, as `faceExtent` is: the paragraphs of a pass share their
|
|
4457
|
+
* styles, and spelling a face into a string key for each was a millisecond
|
|
4458
|
+
* of every pass over a long document.
|
|
4459
|
+
*/
|
|
4460
|
+
const NATURAL_LINE_HEIGHT = new WeakMap<
|
|
4461
|
+
FontsLike,
|
|
4462
|
+
{ byStyle: WeakMap<ComputedStyle, number>; byFace: Map<string, number> }
|
|
4463
|
+
>();
|
|
4464
|
+
|
|
868
4465
|
function naturalLineHeight(fonts: FontsLike, style: ComputedStyle): number {
|
|
4466
|
+
let cache = NATURAL_LINE_HEIGHT.get(fonts);
|
|
4467
|
+
if (!cache) {
|
|
4468
|
+
cache = { byStyle: new WeakMap(), byFace: new Map() };
|
|
4469
|
+
NATURAL_LINE_HEIGHT.set(fonts, cache);
|
|
4470
|
+
}
|
|
4471
|
+
const known = cache.byStyle.get(style);
|
|
4472
|
+
if (known !== undefined) return known;
|
|
4473
|
+
const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight}|${style.fontStyle}`;
|
|
4474
|
+
let height = cache.byFace.get(key);
|
|
4475
|
+
if (height === undefined) {
|
|
4476
|
+
try {
|
|
4477
|
+
const font = fonts.match(style.fontFamily, {
|
|
4478
|
+
size: style.fontSize,
|
|
4479
|
+
weight: style.fontWeight,
|
|
4480
|
+
style: style.fontStyle,
|
|
4481
|
+
});
|
|
4482
|
+
height = font.metrics(style.fontSize).lineHeight;
|
|
4483
|
+
} catch {
|
|
4484
|
+
height = style.fontSize * 1.2;
|
|
4485
|
+
}
|
|
4486
|
+
if (cache.byFace.size > 64) cache.byFace.clear();
|
|
4487
|
+
cache.byFace.set(key, height);
|
|
4488
|
+
}
|
|
4489
|
+
cache.byStyle.set(style, height);
|
|
4490
|
+
return height;
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
/**
|
|
4494
|
+
* A block's strut: the ascent and descent of a line of nothing in its face,
|
|
4495
|
+
* the half-leading its `line-height` adds shared above and below, as the
|
|
4496
|
+
* text engines set a line (CSS 2.1 10.8.1).
|
|
4497
|
+
*/
|
|
4498
|
+
export function strutOf(
|
|
4499
|
+
fonts: FontsLike,
|
|
4500
|
+
style: ComputedStyle,
|
|
4501
|
+
): InlineDecoration {
|
|
4502
|
+
// text set at no size takes no room, and CoreText reads a size of 0 as
|
|
4503
|
+
// its default twelve points
|
|
4504
|
+
if (!(style.fontSize > 0)) return NO_EXTENT;
|
|
4505
|
+
const face = faceExtent(fonts, style);
|
|
4506
|
+
const target =
|
|
4507
|
+
style.lineHeight === 'normal'
|
|
4508
|
+
? naturalLineHeight(fonts, style)
|
|
4509
|
+
: style.lineHeightIsLength
|
|
4510
|
+
? (style.lineHeight as number)
|
|
4511
|
+
: (style.lineHeight as number) * style.fontSize;
|
|
4512
|
+
const half = (target - face.ascent - face.descent) / 2;
|
|
4513
|
+
return { ascent: face.ascent + half, descent: face.descent + half };
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
/** Where a text `vertical-align` raised goes on its line. */
|
|
4517
|
+
interface Lift {
|
|
4518
|
+
/** How far above the line's baseline its baseline is — above the top
|
|
4519
|
+
* or bottom box's, under one of those. */
|
|
4520
|
+
raise: number;
|
|
4521
|
+
/** The room its own inline box takes about its baseline. */
|
|
4522
|
+
ascent: number;
|
|
4523
|
+
descent: number;
|
|
4524
|
+
/** A `top` or `bottom` box it is in, aligned with the line box's edge
|
|
4525
|
+
* rather than with anything's baseline, and the room that box's own
|
|
4526
|
+
* font takes: its content on the line adds to it (`finishLine`). */
|
|
4527
|
+
edge: {
|
|
4528
|
+
box: Box;
|
|
4529
|
+
to: 'top' | 'bottom';
|
|
4530
|
+
ascent: number;
|
|
4531
|
+
descent: number;
|
|
4532
|
+
} | null;
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
/**
|
|
4536
|
+
* The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
|
|
4537
|
+
* 10.8.1): each from its parent's baseline, so a box inside a raised box is
|
|
4538
|
+
* raised with it. `sub` and `super` by a fifth and a third of the parent's
|
|
4539
|
+
* font size and a pixel, as browsers set them; `text-top` and `text-bottom`
|
|
4540
|
+
* to the edges of the parent's font; `middle` by its middle to half the
|
|
4541
|
+
* parent's x-height above its baseline; a length by itself and a
|
|
4542
|
+
* percentage of the box's own line height. Worked out once a box a pass.
|
|
4543
|
+
*/
|
|
4544
|
+
class Lifts {
|
|
4545
|
+
private _boxes = new Map<
|
|
4546
|
+
Box,
|
|
4547
|
+
{ raise: number; edge: Box | null; lead: boolean }
|
|
4548
|
+
>();
|
|
4549
|
+
|
|
4550
|
+
constructor(
|
|
4551
|
+
private readonly fonts: FontsLike,
|
|
4552
|
+
private readonly block: ComputedStyle,
|
|
4553
|
+
/** A box's style on the lines these are for: its own, or the one it
|
|
4554
|
+
* has on a first line (`firstLineSetting`). */
|
|
4555
|
+
private readonly styleOf: (box: Box) => ComputedStyle = ownStyle,
|
|
4556
|
+
) {}
|
|
4557
|
+
|
|
4558
|
+
/** Where a text goes, or null for one that is on the line's baseline and
|
|
4559
|
+
* in no box with a line height of its own. */
|
|
4560
|
+
of(text: LineText): Lift | null {
|
|
4561
|
+
const owner = text.spans.boxAt?.(text.layoutStart);
|
|
4562
|
+
const parent = owner?.parent;
|
|
4563
|
+
if (!owner || !parent || parent.kind !== 'inline') return null;
|
|
4564
|
+
const { raise, edge, lead } = this._box(parent);
|
|
4565
|
+
if (!raise && !edge && !lead) return null;
|
|
4566
|
+
// the room of every box whose text is in it: no raised box starts or
|
|
4567
|
+
// ends inside a fragment, but one it holds may be set larger
|
|
4568
|
+
const own = { ...strutOf(this.fonts, this.styleOf(owner)) };
|
|
4569
|
+
// and of the inline boxes around it, each about its own baseline: each
|
|
4570
|
+
// is on the line with its line height, which the text's may not reach.
|
|
4571
|
+
// Not only where one sets a line height of its own: a raised `<sup>`
|
|
4572
|
+
// holding a smaller `<a>` — every footnote mark — is as tall on the
|
|
4573
|
+
// line as its own font and line height make it (CSS 2.1 10.8)
|
|
4574
|
+
if (lead || raise) {
|
|
4575
|
+
for (let at: Box | null = parent; at?.kind === 'inline'; at = at.parent) {
|
|
4576
|
+
const box = this._box(at);
|
|
4577
|
+
const room = strutOf(this.fonts, this.styleOf(at));
|
|
4578
|
+
own.ascent = Math.max(own.ascent, room.ascent + box.raise - raise);
|
|
4579
|
+
own.descent = Math.max(own.descent, room.descent - box.raise + raise);
|
|
4580
|
+
if (box.edge === at) break;
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
const natural = text.layout.lines[text.layoutLine];
|
|
4584
|
+
let seen = this.styleOf(owner);
|
|
4585
|
+
for (const run of natural?.runs ?? []) {
|
|
4586
|
+
const at = text.spans.boxAt?.(run.start);
|
|
4587
|
+
const style = at && this.styleOf(at);
|
|
4588
|
+
if (!style || style === seen) continue;
|
|
4589
|
+
seen = style;
|
|
4590
|
+
const room = strutOf(this.fonts, style);
|
|
4591
|
+
own.ascent = Math.max(own.ascent, room.ascent);
|
|
4592
|
+
own.descent = Math.max(own.descent, room.descent);
|
|
4593
|
+
}
|
|
4594
|
+
let to: Lift['edge'] = null;
|
|
4595
|
+
if (edge) {
|
|
4596
|
+
const room = strutOf(this.fonts, this.styleOf(edge));
|
|
4597
|
+
to = {
|
|
4598
|
+
box: edge,
|
|
4599
|
+
to: this.styleOf(edge).verticalAlign === 'top' ? 'top' : 'bottom',
|
|
4600
|
+
ascent: room.ascent,
|
|
4601
|
+
descent: room.descent,
|
|
4602
|
+
};
|
|
4603
|
+
}
|
|
4604
|
+
return { raise, ascent: own.ascent, descent: own.descent, edge: to };
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
/** Where an inline box's own room is about the line's baseline — its
|
|
4608
|
+
* face at its line height, raised as it is — or null for one on the
|
|
4609
|
+
* baseline, or one the line's edge sets. */
|
|
4610
|
+
ofBox(box: Box): { raise: number; ascent: number; descent: number } | null {
|
|
4611
|
+
const { raise, edge, lead } = this._box(box);
|
|
4612
|
+
if (edge) return null;
|
|
4613
|
+
if (!raise && !lead && !tallerStrut(this.fonts, this.block, box)) {
|
|
4614
|
+
return null;
|
|
4615
|
+
}
|
|
4616
|
+
const room = strutOf(this.fonts, this.styleOf(box));
|
|
4617
|
+
return { raise, ascent: room.ascent, descent: room.descent };
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
/** The `top` or `bottom` box a box is in, itself included, and how far
|
|
4621
|
+
* its baseline is raised from that box's; null outside one. */
|
|
4622
|
+
edgeOf(box: Box): { edge: Box; raise: number } | null {
|
|
4623
|
+
const lift = this._box(box);
|
|
4624
|
+
return lift.edge ? { edge: lift.edge, raise: lift.raise } : null;
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4627
|
+
private _box(box: Box): { raise: number; edge: Box | null; lead: boolean } {
|
|
4628
|
+
if (box.kind !== 'inline' || !box.parent) return NO_LIFT;
|
|
4629
|
+
const known = this._boxes.get(box);
|
|
4630
|
+
if (known) return known;
|
|
4631
|
+
const va = this.styleOf(box).verticalAlign;
|
|
4632
|
+
const own = ownsLeading(this.fonts, this.block, this.styleOf(box));
|
|
4633
|
+
let lift: { raise: number; edge: Box | null; lead: boolean };
|
|
4634
|
+
if (va === 'top' || va === 'bottom') {
|
|
4635
|
+
// what is in it is raised from its baseline, which the line's edge
|
|
4636
|
+
// sets, whatever is around it
|
|
4637
|
+
lift = { raise: 0, edge: box, lead: own };
|
|
4638
|
+
} else {
|
|
4639
|
+
const up = this._box(box.parent);
|
|
4640
|
+
lift = {
|
|
4641
|
+
raise: up.raise + this._own(box, box.parent),
|
|
4642
|
+
edge: up.edge,
|
|
4643
|
+
lead: own || up.lead,
|
|
4644
|
+
};
|
|
4645
|
+
if (lift.raise && !lift.edge) BOX_RAISES.set(box, lift.raise);
|
|
4646
|
+
}
|
|
4647
|
+
this._boxes.set(box, lift);
|
|
4648
|
+
return lift;
|
|
4649
|
+
}
|
|
4650
|
+
|
|
4651
|
+
/** How far a box's own `vertical-align` raises it from its parent's
|
|
4652
|
+
* baseline. */
|
|
4653
|
+
private _own(box: Box, parent: Box): number {
|
|
4654
|
+
const va = this.styleOf(box).verticalAlign;
|
|
4655
|
+
const size = this.styleOf(parent).fontSize;
|
|
4656
|
+
switch (va) {
|
|
4657
|
+
case 'baseline':
|
|
4658
|
+
case 'top':
|
|
4659
|
+
case 'bottom':
|
|
4660
|
+
return 0;
|
|
4661
|
+
case 'sub':
|
|
4662
|
+
return -(size / 5 + size / 16);
|
|
4663
|
+
case 'super':
|
|
4664
|
+
return size / 3 + size / 16;
|
|
4665
|
+
case 'text-top':
|
|
4666
|
+
return (
|
|
4667
|
+
faceExtent(this.fonts, this.styleOf(parent)).ascent -
|
|
4668
|
+
strutOf(this.fonts, this.styleOf(box)).ascent
|
|
4669
|
+
);
|
|
4670
|
+
case 'text-bottom':
|
|
4671
|
+
return (
|
|
4672
|
+
strutOf(this.fonts, this.styleOf(box)).descent -
|
|
4673
|
+
faceExtent(this.fonts, this.styleOf(parent)).descent
|
|
4674
|
+
);
|
|
4675
|
+
case 'middle': {
|
|
4676
|
+
const own = strutOf(this.fonts, this.styleOf(box));
|
|
4677
|
+
return (
|
|
4678
|
+
xHeightOf(this.fonts, this.styleOf(parent)) / 2 -
|
|
4679
|
+
(own.ascent - own.descent) / 2
|
|
4680
|
+
);
|
|
4681
|
+
}
|
|
4682
|
+
default:
|
|
4683
|
+
return typeof va === 'number'
|
|
4684
|
+
? va
|
|
4685
|
+
: resolve(va, lineHeightOf(this.fonts, this.styleOf(box)));
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4690
|
+
const ownStyle = (box: Box): ComputedStyle => box.style;
|
|
4691
|
+
|
|
4692
|
+
const NO_LIFT = { raise: 0, edge: null, lead: false };
|
|
4693
|
+
|
|
4694
|
+
/**
|
|
4695
|
+
* Whether an inline box's own line height sets its line otherwise than the
|
|
4696
|
+
* one a paragraph's layout gives its text. That layout sets every run at
|
|
4697
|
+
* the block's line height, as a multiple of the run's font's natural one;
|
|
4698
|
+
* CSS gives each inline box its own, and the line box holds them all (CSS
|
|
4699
|
+
* 2.1 10.8.1). So a box whose own is more makes its line taller, and a box
|
|
4700
|
+
* whose own is less makes it shorter than the multiple does, where the
|
|
4701
|
+
* multiple is more than the block's line height too: a larger face under
|
|
4702
|
+
* a `line-height` length, which every inline box inherits as that length —
|
|
4703
|
+
* `font: 11px/15px` on the body and a 14px heading run inline, which the
|
|
4704
|
+
* multiple set 19px tall on a line CSS makes 16.5. One whose own is less
|
|
4705
|
+
* but which the block's line height holds anyway, a `<code>` in a font
|
|
4706
|
+
* with taller natural lines, is left to the one layout: its lines come out
|
|
4707
|
+
* the same, and the line at a time costs a long document dear.
|
|
4708
|
+
*
|
|
4709
|
+
* Asked of every inline box in every paragraph, so it has to cost nothing
|
|
4710
|
+
* for the ones that are nothing: a box with its block's line height, family
|
|
4711
|
+
* and size is past at once, and any other answer is kept on its style.
|
|
4712
|
+
*/
|
|
4713
|
+
function ownsLeading(
|
|
4714
|
+
fonts: FontsLike,
|
|
4715
|
+
block: ComputedStyle,
|
|
4716
|
+
style: ComputedStyle,
|
|
4717
|
+
): boolean {
|
|
4718
|
+
// a bold or an italic face keeps its family's line metrics, and a
|
|
4719
|
+
// paragraph is thick with <strong>, <em> and <a>
|
|
4720
|
+
if (
|
|
4721
|
+
style.lineHeight === block.lineHeight &&
|
|
4722
|
+
style.lineHeightIsLength === block.lineHeightIsLength &&
|
|
4723
|
+
style.fontSize === block.fontSize &&
|
|
4724
|
+
style.fontFamily === block.fontFamily
|
|
4725
|
+
) {
|
|
4726
|
+
return false;
|
|
4727
|
+
}
|
|
4728
|
+
const known = LEADS.get(style);
|
|
4729
|
+
if (known && known.block === block && known.fonts === fonts) {
|
|
4730
|
+
return known.own;
|
|
4731
|
+
}
|
|
4732
|
+
const multiple =
|
|
4733
|
+
lineHeightMultiplier(fonts, block) * naturalLineHeight(fonts, style);
|
|
4734
|
+
const height = lineHeightOf(fonts, style);
|
|
4735
|
+
const own =
|
|
4736
|
+
height > multiple + 0.5 ||
|
|
4737
|
+
multiple > Math.max(height, lineHeightOf(fonts, block)) + 0.5;
|
|
4738
|
+
LEADS.set(style, { fonts, block, own });
|
|
4739
|
+
return own;
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
/** Whether a text is in an inline box that owns its leading
|
|
4743
|
+
* (`ownsLeading`), below the block it is laid out in. */
|
|
4744
|
+
function inBoxOwningLeading(fonts: FontsLike, block: Box, text: Box): boolean {
|
|
4745
|
+
for (let at = text.parent; at && at !== block; at = at.parent) {
|
|
4746
|
+
if (at.kind !== 'inline') break;
|
|
4747
|
+
if (ownsLeading(fonts, block.style, at.style)) return true;
|
|
4748
|
+
}
|
|
4749
|
+
return false;
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
const LEADS = new WeakMap<
|
|
4753
|
+
ComputedStyle,
|
|
4754
|
+
{ fonts: FontsLike; block: ComputedStyle; own: boolean }
|
|
4755
|
+
>();
|
|
4756
|
+
|
|
4757
|
+
/**
|
|
4758
|
+
* Whether an inline box with no text is taller on its line than the
|
|
4759
|
+
* paragraph's own line height: what it holds there, which the engine,
|
|
4760
|
+
* which lays out text, gives no room (CSS 2.1 10.8). A box with text is
|
|
4761
|
+
* given its face's room with the text, at the paragraph's leading, which
|
|
4762
|
+
* `ownsLeading` compares its own line height with; asked of one with none,
|
|
4763
|
+
* the border of an empty `<span>` in a large face was drawn over the text
|
|
4764
|
+
* above its line, which was as short as the paragraph's. Not asked of a
|
|
4765
|
+
* box with text, which the one-layout paths lay out whole: a padded
|
|
4766
|
+
* `<code>` in a paragraph would have been laid out a line at a time.
|
|
4767
|
+
*/
|
|
4768
|
+
function tallerStrut(
|
|
4769
|
+
fonts: FontsLike,
|
|
4770
|
+
block: ComputedStyle,
|
|
4771
|
+
box: Box,
|
|
4772
|
+
): boolean {
|
|
4773
|
+
if (box.subtreeTextEnd > box.subtreeTextStart) return false;
|
|
4774
|
+
const style = box.style;
|
|
4775
|
+
if (
|
|
4776
|
+
style.fontSize === block.fontSize &&
|
|
4777
|
+
style.lineHeight === block.lineHeight
|
|
4778
|
+
) {
|
|
4779
|
+
return false;
|
|
4780
|
+
}
|
|
4781
|
+
const own = strutOf(fonts, style);
|
|
4782
|
+
const theirs = strutOf(fonts, block);
|
|
4783
|
+
return own.ascent + own.descent > theirs.ascent + theirs.descent + 0.5;
|
|
4784
|
+
}
|
|
4785
|
+
|
|
4786
|
+
/** A style's line height in pixels. */
|
|
4787
|
+
function lineHeightOf(fonts: FontsLike, style: ComputedStyle): number {
|
|
4788
|
+
if (style.lineHeight === 'normal') return naturalLineHeight(fonts, style);
|
|
4789
|
+
return style.lineHeightIsLength
|
|
4790
|
+
? (style.lineHeight as number)
|
|
4791
|
+
: (style.lineHeight as number) * style.fontSize;
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
/**
|
|
4795
|
+
* A style's x-height, where its font says, or half its size, as `ex` takes
|
|
4796
|
+
* it (CSS 2.1 4.3.2). A face whose OS/2 table is older than version 2 —
|
|
4797
|
+
* DejaVu's — states none, and the engine's answer is then NaN, which is a
|
|
4798
|
+
* number: taken for one, it put an image set `middle` nowhere at all.
|
|
4799
|
+
*/
|
|
4800
|
+
function xHeightOf(fonts: FontsLike, style: ComputedStyle): number {
|
|
869
4801
|
try {
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
4802
|
+
const metrics = fonts
|
|
4803
|
+
.match(style.fontFamily, {
|
|
4804
|
+
size: style.fontSize,
|
|
4805
|
+
weight: style.fontWeight,
|
|
4806
|
+
style: style.fontStyle,
|
|
4807
|
+
})
|
|
4808
|
+
.metrics(style.fontSize) as { xHeight?: number | null };
|
|
4809
|
+
const x = metrics.xHeight;
|
|
4810
|
+
if (typeof x === 'number' && x > 0) return x;
|
|
876
4811
|
} catch {
|
|
877
|
-
|
|
4812
|
+
// no font to ask
|
|
4813
|
+
}
|
|
4814
|
+
return style.fontSize / 2;
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
/** Whether a line has nothing on it yet. */
|
|
4818
|
+
function isEmpty(open: {
|
|
4819
|
+
x: number;
|
|
4820
|
+
indent: number;
|
|
4821
|
+
texts: unknown[];
|
|
4822
|
+
atomics: unknown[];
|
|
4823
|
+
}) {
|
|
4824
|
+
return open.x === open.indent && !open.texts.length && !open.atomics.length;
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
/** Where a line at `y` next has more room, in the block's own space: the
|
|
4828
|
+
* nearest bottom edge of a float beside it, or null when none is. */
|
|
4829
|
+
function belowFloats(
|
|
4830
|
+
options: InlineOptions,
|
|
4831
|
+
y: number,
|
|
4832
|
+
height: number,
|
|
4833
|
+
): number | null {
|
|
4834
|
+
const edge = options.floats?.nextEdgeBelow(options.startY + y, height);
|
|
4835
|
+
return edge == null ? null : Math.max(y + 1, edge - options.startY);
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
/**
|
|
4839
|
+
* Whether a line's first word did not fit its room: the line runs past the
|
|
4840
|
+
* room — a line's width leaves out the white space it ends on, which hangs,
|
|
4841
|
+
* so this is the word, kept whole where `overflow-wrap` says so — or it
|
|
4842
|
+
* ends inside a word — between two letters or digits, where no break is
|
|
4843
|
+
* allowed, so only a line too narrow for the word put one there. Ideographs
|
|
4844
|
+
* and kana break between any two, and are not counted as a word's letters.
|
|
4845
|
+
*/
|
|
4846
|
+
function tooNarrow(
|
|
4847
|
+
runs: TextRun[],
|
|
4848
|
+
end: number,
|
|
4849
|
+
width: number,
|
|
4850
|
+
room: number,
|
|
4851
|
+
): boolean {
|
|
4852
|
+
let at = 0;
|
|
4853
|
+
let before = '';
|
|
4854
|
+
let after = '';
|
|
4855
|
+
for (const run of runs) {
|
|
4856
|
+
const text = run.text;
|
|
4857
|
+
if (end > at && end <= at + text.length) before = text[end - at - 1];
|
|
4858
|
+
if (end >= at && end < at + text.length) after = text[end - at];
|
|
4859
|
+
at += text.length;
|
|
878
4860
|
}
|
|
4861
|
+
if (WORD_CHAR.test(before) && WORD_CHAR.test(after)) return true;
|
|
4862
|
+
return width > room + 0.5 && before !== '';
|
|
879
4863
|
}
|
|
880
4864
|
|
|
4865
|
+
const WORD_CHAR =
|
|
4866
|
+
/^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
|
|
4867
|
+
|
|
881
4868
|
function bandAt(
|
|
882
4869
|
options: InlineOptions,
|
|
883
4870
|
y: number,
|
|
884
4871
|
height: number,
|
|
885
4872
|
): { left: number; right: number } {
|
|
886
4873
|
if (!options.floats) return { left: 0, right: options.width };
|
|
887
|
-
|
|
4874
|
+
// Floats narrow the block's own line, where they reach into it; the
|
|
4875
|
+
// formatting context's edges are not this block's, and a block wider than
|
|
4876
|
+
// its context — a fixed width in a narrow body — keeps its lines whole.
|
|
4877
|
+
const band = options.floats.bandAt(
|
|
4878
|
+
options.startY + y,
|
|
4879
|
+
height,
|
|
4880
|
+
options.originX,
|
|
4881
|
+
options.originX + options.width,
|
|
4882
|
+
);
|
|
888
4883
|
return {
|
|
889
4884
|
left: Math.max(0, band.left - options.originX),
|
|
890
4885
|
right: Math.min(options.width, band.right - options.originX),
|