pretext-pdf 1.1.1 → 1.6.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/CHANGELOG.md +659 -0
- package/README.md +82 -7
- package/dist/allowed-props.d.ts +76 -0
- package/dist/allowed-props.d.ts.map +1 -1
- package/dist/allowed-props.js.map +1 -1
- package/dist/assets/generators/barcode.d.ts +9 -0
- package/dist/assets/generators/barcode.d.ts.map +1 -0
- package/dist/assets/generators/barcode.js +24 -0
- package/dist/assets/generators/barcode.js.map +1 -0
- package/dist/assets/generators/chart.d.ts +13 -0
- package/dist/assets/generators/chart.d.ts.map +1 -0
- package/dist/assets/generators/chart.js +32 -0
- package/dist/assets/generators/chart.js.map +1 -0
- package/dist/assets/generators/qr.d.ts +9 -0
- package/dist/assets/generators/qr.d.ts.map +1 -0
- package/dist/assets/generators/qr.js +25 -0
- package/dist/assets/generators/qr.js.map +1 -0
- package/dist/assets/index.d.ts +19 -0
- package/dist/assets/index.d.ts.map +1 -0
- package/dist/assets/index.js +19 -0
- package/dist/assets/index.js.map +1 -0
- package/dist/assets/loaders/images.d.ts +20 -0
- package/dist/assets/loaders/images.d.ts.map +1 -0
- package/dist/assets/loaders/images.js +69 -0
- package/dist/assets/loaders/images.js.map +1 -0
- package/dist/assets/loaders/orchestrator.d.ts +24 -0
- package/dist/assets/loaders/orchestrator.d.ts.map +1 -0
- package/dist/assets/loaders/orchestrator.js +109 -0
- package/dist/assets/loaders/orchestrator.js.map +1 -0
- package/dist/assets/loaders/vectors.d.ts +25 -0
- package/dist/assets/loaders/vectors.d.ts.map +1 -0
- package/dist/assets/loaders/vectors.js +118 -0
- package/dist/assets/loaders/vectors.js.map +1 -0
- package/dist/assets/loaders/watermark.d.ts +12 -0
- package/dist/assets/loaders/watermark.d.ts.map +1 -0
- package/dist/assets/loaders/watermark.js +40 -0
- package/dist/assets/loaders/watermark.js.map +1 -0
- package/dist/assets/security/fetch.d.ts +14 -0
- package/dist/assets/security/fetch.d.ts.map +1 -0
- package/dist/assets/security/fetch.js +112 -0
- package/dist/assets/security/fetch.js.map +1 -0
- package/dist/assets/security/ipv4-normalize.d.ts +28 -0
- package/dist/assets/security/ipv4-normalize.d.ts.map +1 -0
- package/dist/assets/security/ipv4-normalize.js +116 -0
- package/dist/assets/security/ipv4-normalize.js.map +1 -0
- package/dist/assets/security/path-allowlist.d.ts +12 -0
- package/dist/assets/security/path-allowlist.d.ts.map +1 -0
- package/dist/assets/security/path-allowlist.js +26 -0
- package/dist/assets/security/path-allowlist.js.map +1 -0
- package/dist/assets/security/url-validation.d.ts +22 -0
- package/dist/assets/security/url-validation.d.ts.map +1 -0
- package/dist/assets/security/url-validation.js +164 -0
- package/dist/assets/security/url-validation.js.map +1 -0
- package/dist/assets/svg/dimensions.d.ts +19 -0
- package/dist/assets/svg/dimensions.d.ts.map +1 -0
- package/dist/assets/svg/dimensions.js +43 -0
- package/dist/assets/svg/dimensions.js.map +1 -0
- package/dist/assets/svg/rasterize.d.ts +6 -0
- package/dist/assets/svg/rasterize.d.ts.map +1 -0
- package/dist/assets/svg/rasterize.js +38 -0
- package/dist/assets/svg/rasterize.js.map +1 -0
- package/dist/assets/svg/resolve-content.d.ts +16 -0
- package/dist/assets/svg/resolve-content.d.ts.map +1 -0
- package/dist/assets/svg/resolve-content.js +38 -0
- package/dist/assets/svg/resolve-content.js.map +1 -0
- package/dist/assets/svg/sanitize.d.ts +22 -0
- package/dist/assets/svg/sanitize.d.ts.map +1 -0
- package/dist/assets/svg/sanitize.js +46 -0
- package/dist/assets/svg/sanitize.js.map +1 -0
- package/dist/assets/util/redact-path.d.ts +14 -0
- package/dist/assets/util/redact-path.d.ts.map +1 -0
- package/dist/assets/util/redact-path.js +16 -0
- package/dist/assets/util/redact-path.js.map +1 -0
- package/dist/assets.d.ts +10 -27
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +10 -549
- package/dist/assets.js.map +1 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +2 -1
- package/dist/builder.js.map +1 -1
- package/dist/cli.js +11 -1
- package/dist/cli.js.map +1 -1
- package/dist/compat.d.ts +63 -1
- package/dist/compat.d.ts.map +1 -1
- package/dist/compat.js +42 -5
- package/dist/compat.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +8 -10
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/layout-state.d.ts +1 -1
- package/dist/layout-state.d.ts.map +1 -1
- package/dist/layout-state.js +5 -0
- package/dist/layout-state.js.map +1 -1
- package/dist/measure-blocks/float-group.d.ts +9 -0
- package/dist/measure-blocks/float-group.d.ts.map +1 -0
- package/dist/measure-blocks/float-group.js +103 -0
- package/dist/measure-blocks/float-group.js.map +1 -0
- package/dist/measure-blocks/helpers.d.ts +44 -0
- package/dist/measure-blocks/helpers.d.ts.map +1 -0
- package/dist/measure-blocks/helpers.js +43 -0
- package/dist/measure-blocks/helpers.js.map +1 -0
- package/dist/measure-blocks/highlight.d.ts +26 -0
- package/dist/measure-blocks/highlight.d.ts.map +1 -0
- package/dist/measure-blocks/highlight.js +169 -0
- package/dist/measure-blocks/highlight.js.map +1 -0
- package/dist/measure-blocks/image.d.ts +9 -0
- package/dist/measure-blocks/image.d.ts.map +1 -0
- package/dist/measure-blocks/image.js +136 -0
- package/dist/measure-blocks/image.js.map +1 -0
- package/dist/measure-blocks/index.d.ts +24 -0
- package/dist/measure-blocks/index.d.ts.map +1 -0
- package/dist/measure-blocks/index.js +179 -0
- package/dist/measure-blocks/index.js.map +1 -0
- package/dist/measure-blocks/list.d.ts +8 -0
- package/dist/measure-blocks/list.d.ts.map +1 -0
- package/dist/measure-blocks/list.js +108 -0
- package/dist/measure-blocks/list.js.map +1 -0
- package/dist/measure-blocks/simple-blocks.d.ts +18 -0
- package/dist/measure-blocks/simple-blocks.d.ts.map +1 -0
- package/dist/measure-blocks/simple-blocks.js +121 -0
- package/dist/measure-blocks/simple-blocks.js.map +1 -0
- package/dist/measure-blocks/table/columns.d.ts +17 -0
- package/dist/measure-blocks/table/columns.d.ts.map +1 -0
- package/dist/measure-blocks/table/columns.js +83 -0
- package/dist/measure-blocks/table/columns.js.map +1 -0
- package/dist/measure-blocks/table/measure.d.ts +8 -0
- package/dist/measure-blocks/table/measure.d.ts.map +1 -0
- package/dist/measure-blocks/table/measure.js +231 -0
- package/dist/measure-blocks/table/measure.js.map +1 -0
- package/dist/measure-blocks/table/spans.d.ts +25 -0
- package/dist/measure-blocks/table/spans.d.ts.map +1 -0
- package/dist/measure-blocks/table/spans.js +55 -0
- package/dist/measure-blocks/table/spans.js.map +1 -0
- package/dist/measure-blocks/text-blocks.d.ts +17 -0
- package/dist/measure-blocks/text-blocks.d.ts.map +1 -0
- package/dist/measure-blocks/text-blocks.js +242 -0
- package/dist/measure-blocks/text-blocks.js.map +1 -0
- package/dist/measure-text.d.ts +21 -3
- package/dist/measure-text.d.ts.map +1 -1
- package/dist/measure-text.js +87 -36
- package/dist/measure-text.js.map +1 -1
- package/dist/measure.d.ts +1 -1
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +8 -6
- package/dist/measure.js.map +1 -1
- package/dist/node-polyfill.d.ts.map +1 -1
- package/dist/node-polyfill.js +9 -0
- package/dist/node-polyfill.js.map +1 -1
- package/dist/pipeline-footnotes.d.ts +1 -1
- package/dist/pipeline-footnotes.d.ts.map +1 -1
- package/dist/pipeline-toc.d.ts +1 -1
- package/dist/pipeline-toc.d.ts.map +1 -1
- package/dist/pipeline.d.ts +3 -3
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +4 -5
- package/dist/pipeline.js.map +1 -1
- package/dist/plugin-types.d.ts +1 -1
- package/dist/plugin-types.d.ts.map +1 -1
- package/dist/post-process.d.ts +2 -2
- package/dist/post-process.d.ts.map +1 -1
- package/dist/post-process.js +32 -9
- package/dist/post-process.js.map +1 -1
- package/dist/render-blocks/blockquote.d.ts +7 -0
- package/dist/render-blocks/blockquote.d.ts.map +1 -0
- package/dist/render-blocks/blockquote.js +87 -0
- package/dist/render-blocks/blockquote.js.map +1 -0
- package/dist/render-blocks/callout.d.ts +7 -0
- package/dist/render-blocks/callout.d.ts.map +1 -0
- package/dist/render-blocks/callout.js +84 -0
- package/dist/render-blocks/callout.js.map +1 -0
- package/dist/render-blocks/code.d.ts +7 -0
- package/dist/render-blocks/code.d.ts.map +1 -0
- package/dist/render-blocks/code.js +84 -0
- package/dist/render-blocks/code.js.map +1 -0
- package/dist/render-blocks/footnote.d.ts +11 -0
- package/dist/render-blocks/footnote.d.ts.map +1 -0
- package/dist/render-blocks/footnote.js +45 -0
- package/dist/render-blocks/footnote.js.map +1 -0
- package/dist/render-blocks/header-footer.d.ts +11 -0
- package/dist/render-blocks/header-footer.d.ts.map +1 -0
- package/dist/render-blocks/header-footer.js +56 -0
- package/dist/render-blocks/header-footer.js.map +1 -0
- package/dist/render-blocks/hr.d.ts +7 -0
- package/dist/render-blocks/hr.d.ts.map +1 -0
- package/dist/render-blocks/hr.js +24 -0
- package/dist/render-blocks/hr.js.map +1 -0
- package/dist/render-blocks/image.d.ts +9 -0
- package/dist/render-blocks/image.d.ts.map +1 -0
- package/dist/render-blocks/image.js +135 -0
- package/dist/render-blocks/image.js.map +1 -0
- package/dist/render-blocks/index.d.ts +17 -0
- package/dist/render-blocks/index.d.ts.map +1 -0
- package/dist/render-blocks/index.js +17 -0
- package/dist/render-blocks/index.js.map +1 -0
- package/dist/render-blocks/list-item.d.ts +7 -0
- package/dist/render-blocks/list-item.d.ts.map +1 -0
- package/dist/render-blocks/list-item.js +80 -0
- package/dist/render-blocks/list-item.js.map +1 -0
- package/dist/render-blocks/rich.d.ts +7 -0
- package/dist/render-blocks/rich.d.ts.map +1 -0
- package/dist/render-blocks/rich.js +160 -0
- package/dist/render-blocks/rich.js.map +1 -0
- package/dist/render-blocks/table.d.ts +7 -0
- package/dist/render-blocks/table.d.ts.map +1 -0
- package/dist/render-blocks/table.js +139 -0
- package/dist/render-blocks/table.js.map +1 -0
- package/dist/render-blocks/text.d.ts +7 -0
- package/dist/render-blocks/text.d.ts.map +1 -0
- package/dist/render-blocks/text.js +183 -0
- package/dist/render-blocks/text.js.map +1 -0
- package/dist/render-blocks/watermark.d.ts +8 -0
- package/dist/render-blocks/watermark.d.ts.map +1 -0
- package/dist/render-blocks/watermark.js +52 -0
- package/dist/render-blocks/watermark.js.map +1 -0
- package/dist/render-extras.d.ts.map +1 -1
- package/dist/render-extras.js +1 -2
- package/dist/render-extras.js.map +1 -1
- package/dist/render-utils.d.ts.map +1 -1
- package/dist/render-utils.js +10 -6
- package/dist/render-utils.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +9 -3
- package/dist/render.js.map +1 -1
- package/dist/rich-text.d.ts +2 -1
- package/dist/rich-text.d.ts.map +1 -1
- package/dist/rich-text.js +0 -1
- package/dist/rich-text.js.map +1 -1
- package/dist/types-internal.d.ts +19 -3
- package/dist/types-internal.d.ts.map +1 -1
- package/dist/types-public/document.d.ts +261 -0
- package/dist/types-public/document.d.ts.map +1 -0
- package/dist/types-public/document.js +2 -0
- package/dist/types-public/document.js.map +1 -0
- package/dist/types-public/elements-block.d.ts +246 -0
- package/dist/types-public/elements-block.d.ts.map +1 -0
- package/dist/types-public/elements-block.js +8 -0
- package/dist/types-public/elements-block.js.map +1 -0
- package/dist/types-public/elements-media.d.ts +199 -0
- package/dist/types-public/elements-media.d.ts.map +1 -0
- package/dist/types-public/elements-media.js +2 -0
- package/dist/types-public/elements-media.js.map +1 -0
- package/dist/types-public/elements-text.d.ts +327 -0
- package/dist/types-public/elements-text.d.ts.map +1 -0
- package/dist/types-public/elements-text.js +2 -0
- package/dist/types-public/elements-text.js.map +1 -0
- package/dist/types-public/index.d.ts +14 -0
- package/dist/types-public/index.d.ts.map +1 -0
- package/dist/types-public/index.js +2 -0
- package/dist/types-public/index.js.map +1 -0
- package/dist/types-public/render-options.d.ts +38 -0
- package/dist/types-public/render-options.d.ts.map +1 -0
- package/dist/types-public/render-options.js +2 -0
- package/dist/types-public/render-options.js.map +1 -0
- package/dist/types-public/union.d.ts +13 -0
- package/dist/types-public/union.d.ts.map +1 -0
- package/dist/types-public/union.js +2 -0
- package/dist/types-public/union.js.map +1 -0
- package/dist/types-public/validation.d.ts +64 -0
- package/dist/types-public/validation.d.ts.map +1 -0
- package/dist/types-public/validation.js +2 -0
- package/dist/types-public/validation.js.map +1 -0
- package/dist/types-public.d.ts +5 -1081
- package/dist/types-public.d.ts.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/validate/document.d.ts +28 -0
- package/dist/validate/document.d.ts.map +1 -0
- package/dist/validate/document.js +295 -0
- package/dist/validate/document.js.map +1 -0
- package/dist/validate/elements/forms-floats.d.ts +19 -0
- package/dist/validate/elements/forms-floats.d.ts.map +1 -0
- package/dist/validate/elements/forms-floats.js +96 -0
- package/dist/validate/elements/forms-floats.js.map +1 -0
- package/dist/validate/elements/list.d.ts +10 -0
- package/dist/validate/elements/list.d.ts.map +1 -0
- package/dist/validate/elements/list.js +66 -0
- package/dist/validate/elements/list.js.map +1 -0
- package/dist/validate/elements/media.d.ts +23 -0
- package/dist/validate/elements/media.d.ts.map +1 -0
- package/dist/validate/elements/media.js +179 -0
- package/dist/validate/elements/media.js.map +1 -0
- package/dist/validate/elements/structural-simple.d.ts +21 -0
- package/dist/validate/elements/structural-simple.d.ts.map +1 -0
- package/dist/validate/elements/structural-simple.js +63 -0
- package/dist/validate/elements/structural-simple.js.map +1 -0
- package/dist/validate/elements/structural.d.ts +12 -0
- package/dist/validate/elements/structural.d.ts.map +1 -0
- package/dist/validate/elements/structural.js +12 -0
- package/dist/validate/elements/structural.js.map +1 -0
- package/dist/validate/elements/table.d.ts +10 -0
- package/dist/validate/elements/table.d.ts.map +1 -0
- package/dist/validate/elements/table.js +165 -0
- package/dist/validate/elements/table.js.map +1 -0
- package/dist/validate/elements/text.d.ts +26 -0
- package/dist/validate/elements/text.d.ts.map +1 -0
- package/dist/validate/elements/text.js +331 -0
- package/dist/validate/elements/text.js.map +1 -0
- package/dist/validate/errors.d.ts +9 -0
- package/dist/validate/errors.d.ts.map +1 -0
- package/dist/validate/errors.js +43 -0
- package/dist/validate/errors.js.map +1 -0
- package/dist/validate/fonts.d.ts +11 -0
- package/dist/validate/fonts.d.ts.map +1 -0
- package/dist/validate/fonts.js +118 -0
- package/dist/validate/fonts.js.map +1 -0
- package/dist/validate/helpers.d.ts +76 -0
- package/dist/validate/helpers.d.ts.map +1 -0
- package/dist/validate/helpers.js +169 -0
- package/dist/validate/helpers.js.map +1 -0
- package/dist/validate/index.d.ts +37 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +279 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/validate.d.ts +6 -18
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +6 -1585
- package/dist/validate.js.map +1 -1
- package/dist/vendor/pretext/VERSION.d.ts +3 -0
- package/dist/vendor/pretext/VERSION.d.ts.map +1 -0
- package/dist/vendor/pretext/VERSION.js +12 -0
- package/dist/vendor/pretext/VERSION.js.map +1 -0
- package/dist/version-check.d.ts +47 -0
- package/dist/version-check.d.ts.map +1 -0
- package/dist/version-check.js +75 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +26 -7
- package/dist/measure-blocks.d.ts +0 -26
- package/dist/measure-blocks.d.ts.map +0 -1
- package/dist/measure-blocks.js +0 -1317
- package/dist/measure-blocks.js.map +0 -1
- package/dist/render-blocks.d.ts +0 -28
- package/dist/render-blocks.d.ts.map +0 -1
- package/dist/render-blocks.js +0 -1059
- package/dist/render-blocks.js.map +0 -1
package/dist/measure-blocks.js
DELETED
|
@@ -1,1317 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* measure-blocks.ts — Per-element-type measurement functions
|
|
3
|
-
* All the specific measurer functions for different content types.
|
|
4
|
-
*/
|
|
5
|
-
import { PretextPdfError } from './errors.js';
|
|
6
|
-
import { measureRichText } from './rich-text.js';
|
|
7
|
-
import { buildFontKey } from './measure.js';
|
|
8
|
-
import { measureText, getPretext, detectAndReorderRTL } from './measure-text.js';
|
|
9
|
-
import { LINE_HEIGHT_BODY, LINE_HEIGHT_COMPACT } from './render-utils.js';
|
|
10
|
-
/** Heading level size multipliers and defaults */
|
|
11
|
-
const HEADING_DEFAULTS = {
|
|
12
|
-
1: { sizeMultiplier: 2.0, fontWeight: 700, spaceAfter: 16, spaceBefore: 28 },
|
|
13
|
-
2: { sizeMultiplier: 1.5, fontWeight: 700, spaceAfter: 12, spaceBefore: 24 },
|
|
14
|
-
3: { sizeMultiplier: 1.25, fontWeight: 700, spaceAfter: 8, spaceBefore: 20 },
|
|
15
|
-
4: { sizeMultiplier: 1.1, fontWeight: 700, spaceAfter: 6, spaceBefore: 16 },
|
|
16
|
-
};
|
|
17
|
-
/**
|
|
18
|
-
* Resolve preset callout style colors
|
|
19
|
-
*/
|
|
20
|
-
function resolveCalloutColors(style) {
|
|
21
|
-
switch (style) {
|
|
22
|
-
case 'info': return { bg: '#EFF6FF', border: '#3B82F6' };
|
|
23
|
-
case 'warning': return { bg: '#FFFBEB', border: '#F59E0B' };
|
|
24
|
-
case 'tip': return { bg: '#F0FDF4', border: '#22C55E' };
|
|
25
|
-
case 'note': return { bg: '#F9FAFB', border: '#9CA3AF' };
|
|
26
|
-
default: return { bg: '#F8F9FA', border: '#0070F3' };
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
30
|
-
const baseFontSize = doc.defaultFontSize ?? 12;
|
|
31
|
-
const baseFont = doc.defaultFont ?? 'Inter';
|
|
32
|
-
switch (element.type) {
|
|
33
|
-
case 'spacer': {
|
|
34
|
-
return {
|
|
35
|
-
element,
|
|
36
|
-
height: element.height,
|
|
37
|
-
lines: [],
|
|
38
|
-
fontSize: 0,
|
|
39
|
-
lineHeight: 0,
|
|
40
|
-
fontKey: '',
|
|
41
|
-
spaceAfter: 0,
|
|
42
|
-
spaceBefore: 0,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
case 'page-break': {
|
|
46
|
-
return {
|
|
47
|
-
element,
|
|
48
|
-
height: 0,
|
|
49
|
-
lines: [],
|
|
50
|
-
fontSize: 0,
|
|
51
|
-
lineHeight: 0,
|
|
52
|
-
fontKey: '',
|
|
53
|
-
spaceAfter: 0,
|
|
54
|
-
spaceBefore: 0,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
case 'comment': {
|
|
58
|
-
return {
|
|
59
|
-
element,
|
|
60
|
-
height: 20,
|
|
61
|
-
lines: [],
|
|
62
|
-
fontSize: 0,
|
|
63
|
-
lineHeight: 0,
|
|
64
|
-
fontKey: '',
|
|
65
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
66
|
-
spaceBefore: 0,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
case 'form-field': {
|
|
70
|
-
const el = element;
|
|
71
|
-
const fs = el.fontSize ?? baseFontSize;
|
|
72
|
-
const labelHeight = el.label ? fs * LINE_HEIGHT_BODY + 4 : 0;
|
|
73
|
-
let fieldHeight = el.height;
|
|
74
|
-
if (!fieldHeight) {
|
|
75
|
-
if (el.fieldType === 'text' && el.multiline)
|
|
76
|
-
fieldHeight = 60;
|
|
77
|
-
else if (el.fieldType === 'radio')
|
|
78
|
-
fieldHeight = 20 * Math.max(1, el.options?.length ?? 1);
|
|
79
|
-
else
|
|
80
|
-
fieldHeight = 24;
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
element,
|
|
84
|
-
height: labelHeight + fieldHeight + (el.spaceAfter ?? 8),
|
|
85
|
-
lines: [],
|
|
86
|
-
fontSize: fs,
|
|
87
|
-
lineHeight: fieldHeight,
|
|
88
|
-
fontKey: buildFontKey(baseFont, 400, 'normal'),
|
|
89
|
-
spaceAfter: el.spaceAfter ?? 8,
|
|
90
|
-
spaceBefore: el.spaceBefore ?? 0,
|
|
91
|
-
formFieldData: { labelHeight, fieldHeight },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
case 'paragraph': {
|
|
95
|
-
// Detect and reorder RTL text
|
|
96
|
-
const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
|
|
97
|
-
const fontSize = element.fontSize ?? doc.defaultParagraphStyle?.fontSize ?? baseFontSize;
|
|
98
|
-
// smallCaps renders at 80% of fontSize — measure at the same size to avoid
|
|
99
|
-
// overestimating block height and wasting vertical space
|
|
100
|
-
const effectiveFontSize = element.smallCaps === true ? fontSize * 0.8 : fontSize;
|
|
101
|
-
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize * LINE_HEIGHT_BODY);
|
|
102
|
-
const fontFamily = element.fontFamily ?? doc.defaultParagraphStyle?.fontFamily ?? baseFont;
|
|
103
|
-
const fontWeight = element.fontWeight ?? doc.defaultParagraphStyle?.fontWeight ?? 400;
|
|
104
|
-
const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
105
|
-
const columns = element.columns ?? 1;
|
|
106
|
-
const columnGap = element.columnGap ?? 24;
|
|
107
|
-
let measureWidth = contentWidth;
|
|
108
|
-
let columnData;
|
|
109
|
-
// Multi-column layout
|
|
110
|
-
let computedColumnWidth = contentWidth;
|
|
111
|
-
if (columns > 1) {
|
|
112
|
-
if (columns > 6) {
|
|
113
|
-
throw new PretextPdfError('VALIDATION_ERROR', `columns must be 1–6, got ${columns}`);
|
|
114
|
-
}
|
|
115
|
-
computedColumnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
|
|
116
|
-
if (computedColumnWidth < 50) {
|
|
117
|
-
throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Column width would be ${computedColumnWidth.toFixed(1)}pt, which is below the minimum 50pt. Reduce columns, increase columnGap, or increase page width.`);
|
|
118
|
-
}
|
|
119
|
-
measureWidth = computedColumnWidth;
|
|
120
|
-
}
|
|
121
|
-
const opts = hyphenatorOpts && element.hyphenate !== false ? hyphenatorOpts : undefined;
|
|
122
|
-
// Compensate for letterSpacing: render adds `spacing` pts after each character,
|
|
123
|
-
// but pretext doesn't know about it. Reduce measureWidth so line-breaks happen
|
|
124
|
-
// before the rendered text would overflow. Formula: scale by avgCharWidth /
|
|
125
|
-
// (avgCharWidth + spacing), where avgCharWidth ≈ 0.5 * effectiveFontSize.
|
|
126
|
-
const letterSpacingValue = element.letterSpacing ?? doc.defaultParagraphStyle?.letterSpacing ?? 0;
|
|
127
|
-
if (letterSpacingValue > 0) {
|
|
128
|
-
const avgCharWidth = effectiveFontSize * 0.5;
|
|
129
|
-
measureWidth = Math.max(10, measureWidth * avgCharWidth / (avgCharWidth + letterSpacingValue));
|
|
130
|
-
}
|
|
131
|
-
// Measure post-reorder (visual-order) text because the renderer draws characters in
|
|
132
|
-
// visual order; measuring the logical string for an RTL run would pick break points
|
|
133
|
-
// that don't match what is actually drawn, producing wrong line widths.
|
|
134
|
-
//
|
|
135
|
-
// smallCaps uppercases at render time. Measure the same uppercase text so
|
|
136
|
-
// line-break widths match what the renderer actually draws.
|
|
137
|
-
const measureText_ = element.smallCaps === true ? visualText.toUpperCase() : visualText;
|
|
138
|
-
const lines = await measureText(measureText_, effectiveFontSize, fontFamily, fontWeight, measureWidth, lineHeight, opts);
|
|
139
|
-
if (columns > 1) {
|
|
140
|
-
const linesPerColumn = Math.max(1, Math.ceil(lines.length / columns));
|
|
141
|
-
columnData = { columnCount: columns, columnGap, columnWidth: computedColumnWidth, linesPerColumn };
|
|
142
|
-
}
|
|
143
|
-
// Construct result with or without columnData depending on columns value
|
|
144
|
-
const paraSpaceAfter = element.spaceAfter ?? doc.defaultParagraphStyle?.spaceAfter ?? 0;
|
|
145
|
-
const paraSpaceBefore = element.spaceBefore ?? doc.defaultParagraphStyle?.spaceBefore ?? 0;
|
|
146
|
-
if (columnData) {
|
|
147
|
-
return {
|
|
148
|
-
element,
|
|
149
|
-
height: columnData.linesPerColumn * lineHeight,
|
|
150
|
-
lines,
|
|
151
|
-
fontSize,
|
|
152
|
-
lineHeight,
|
|
153
|
-
fontKey,
|
|
154
|
-
spaceAfter: paraSpaceAfter,
|
|
155
|
-
spaceBefore: paraSpaceBefore,
|
|
156
|
-
columnData,
|
|
157
|
-
isRTL,
|
|
158
|
-
...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
return {
|
|
163
|
-
element,
|
|
164
|
-
height: lines.length * lineHeight,
|
|
165
|
-
lines,
|
|
166
|
-
fontSize,
|
|
167
|
-
lineHeight,
|
|
168
|
-
fontKey,
|
|
169
|
-
spaceAfter: paraSpaceAfter,
|
|
170
|
-
spaceBefore: paraSpaceBefore,
|
|
171
|
-
isRTL,
|
|
172
|
-
...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
case 'heading': {
|
|
177
|
-
// Detect and reorder RTL text
|
|
178
|
-
const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
|
|
179
|
-
const defaults = HEADING_DEFAULTS[element.level];
|
|
180
|
-
const baseHeadingFontSize = doc.defaultParagraphStyle?.fontSize ?? baseFontSize;
|
|
181
|
-
const fontSize = element.fontSize ?? (baseHeadingFontSize * defaults.sizeMultiplier);
|
|
182
|
-
// smallCaps renders at 80% — measure at effective size
|
|
183
|
-
const effectiveFontSize = element.smallCaps === true ? fontSize * 0.8 : fontSize;
|
|
184
|
-
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize * LINE_HEIGHT_COMPACT);
|
|
185
|
-
const fontFamily = element.fontFamily ?? doc.defaultParagraphStyle?.fontFamily ?? baseFont;
|
|
186
|
-
const fontWeight = element.fontWeight ?? doc.defaultParagraphStyle?.fontWeight ?? defaults.fontWeight;
|
|
187
|
-
const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
188
|
-
const opts = hyphenatorOpts && element.hyphenate !== false ? hyphenatorOpts : undefined;
|
|
189
|
-
// Compensate for letterSpacing (same logic as paragraph above)
|
|
190
|
-
const headingLetterSpacing = element.letterSpacing ?? doc.defaultParagraphStyle?.letterSpacing ?? 0;
|
|
191
|
-
const headingMeasureWidth = headingLetterSpacing > 0
|
|
192
|
-
? Math.max(10, contentWidth * (effectiveFontSize * 0.5) / (effectiveFontSize * 0.5 + headingLetterSpacing))
|
|
193
|
-
: contentWidth;
|
|
194
|
-
// Measure post-reorder (visual-order) text because the renderer draws characters in
|
|
195
|
-
// visual order; measuring the logical string for an RTL run would pick break points
|
|
196
|
-
// that don't match what is actually drawn, producing wrong line widths.
|
|
197
|
-
//
|
|
198
|
-
// smallCaps uppercases at render time. Measure the same uppercase text so
|
|
199
|
-
// line-break widths match what the renderer actually draws.
|
|
200
|
-
const headingMeasureText = element.smallCaps === true ? visualText.toUpperCase() : visualText;
|
|
201
|
-
const lines = await measureText(headingMeasureText, effectiveFontSize, fontFamily, fontWeight, headingMeasureWidth, lineHeight, opts);
|
|
202
|
-
return {
|
|
203
|
-
element,
|
|
204
|
-
height: lines.length * lineHeight,
|
|
205
|
-
lines,
|
|
206
|
-
fontSize,
|
|
207
|
-
lineHeight,
|
|
208
|
-
fontKey,
|
|
209
|
-
spaceAfter: element.spaceAfter ?? doc.defaultParagraphStyle?.spaceAfter ?? defaults.spaceAfter,
|
|
210
|
-
spaceBefore: element.spaceBefore ?? doc.defaultParagraphStyle?.spaceBefore ?? defaults.spaceBefore,
|
|
211
|
-
isRTL,
|
|
212
|
-
...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
case 'hr': {
|
|
216
|
-
const spaceAbove = element.spaceAbove ?? element.spaceBefore ?? 12;
|
|
217
|
-
const thickness = element.thickness ?? 0.5;
|
|
218
|
-
const spaceBelow = element.spaceBelow ?? element.spaceAfter ?? 12;
|
|
219
|
-
return {
|
|
220
|
-
element,
|
|
221
|
-
height: spaceAbove + thickness + spaceBelow,
|
|
222
|
-
lines: [],
|
|
223
|
-
fontSize: 0,
|
|
224
|
-
lineHeight: 0,
|
|
225
|
-
fontKey: '',
|
|
226
|
-
spaceAfter: 0,
|
|
227
|
-
spaceBefore: 0,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
case 'image': {
|
|
231
|
-
// Image elements must be measured via measureAllBlocks() — not measureBlock() directly.
|
|
232
|
-
// measureAllBlocks() resolves the content-index-based imageMap key (img-N) before calling
|
|
233
|
-
// measureImageWithKey(). measureBlock() doesn't have access to the content index.
|
|
234
|
-
throw new PretextPdfError('VALIDATION_ERROR', 'Image elements cannot be measured via measureBlock() directly — use measureAllBlocks() which resolves the imageMap key correctly.');
|
|
235
|
-
}
|
|
236
|
-
case 'svg': {
|
|
237
|
-
// SVG elements must be measured via measureAllBlocks() — not measureBlock() directly.
|
|
238
|
-
// measureAllBlocks() resolves the content-index-based imageMap key (svg-N) before calling
|
|
239
|
-
// measureImageWithKey(). measureBlock() doesn't have access to the content index.
|
|
240
|
-
throw new PretextPdfError('VALIDATION_ERROR', 'SVG elements cannot be measured via measureBlock() directly — use measureAllBlocks() which resolves the imageMap key correctly.');
|
|
241
|
-
}
|
|
242
|
-
case 'list': {
|
|
243
|
-
return measureList(element, contentWidth, doc, baseFontSize, hyphenatorOpts);
|
|
244
|
-
}
|
|
245
|
-
case 'table': {
|
|
246
|
-
return measureTable(element, contentWidth, doc, baseFontSize, hyphenatorOpts);
|
|
247
|
-
}
|
|
248
|
-
case 'rich-paragraph': {
|
|
249
|
-
// Detect paragraph-level RTL direction for alignment default
|
|
250
|
-
// Individual spans can override via span.dir, but paragraph.dir sets the default
|
|
251
|
-
const fullText = element.spans.map(s => s.text).join('');
|
|
252
|
-
const { isRTL } = await detectAndReorderRTL(fullText, element.dir);
|
|
253
|
-
const fontSize = element.fontSize ?? baseFontSize;
|
|
254
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
255
|
-
// 'justify' uses left alignment for measurement (justify is rendering-only)
|
|
256
|
-
const alignRaw = element.align ?? 'left';
|
|
257
|
-
const align = alignRaw === 'justify' ? 'left' : alignRaw;
|
|
258
|
-
const columns = element.columns ?? 1;
|
|
259
|
-
const columnGap = element.columnGap ?? 24;
|
|
260
|
-
let measureWidth = contentWidth;
|
|
261
|
-
let columnData;
|
|
262
|
-
// Multi-column layout
|
|
263
|
-
if (columns > 1) {
|
|
264
|
-
const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
|
|
265
|
-
if (columnWidth < 50) {
|
|
266
|
-
throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Column width would be ${columnWidth.toFixed(1)}pt, which is below the minimum 50pt. Reduce columns, increase columnGap, or increase page width.`);
|
|
267
|
-
}
|
|
268
|
-
measureWidth = columnWidth;
|
|
269
|
-
}
|
|
270
|
-
const richLines = await measureRichText(element.spans, fontSize, lineHeight, measureWidth, align, doc);
|
|
271
|
-
if (columns > 1) {
|
|
272
|
-
const columnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
|
|
273
|
-
const linesPerColumn = Math.ceil(richLines.length / columns);
|
|
274
|
-
columnData = { columnCount: columns, columnGap, columnWidth, linesPerColumn };
|
|
275
|
-
}
|
|
276
|
-
// For paginator/renderer compatibility, also produce a flat lines[] array
|
|
277
|
-
// (used for orphan/widow logic and line-count-based pagination).
|
|
278
|
-
// Each RichLine becomes one PretextLine with its totalWidth.
|
|
279
|
-
const lines = richLines.map(rl => ({
|
|
280
|
-
text: rl.fragments.map(f => f.text).join(''),
|
|
281
|
-
width: rl.totalWidth,
|
|
282
|
-
}));
|
|
283
|
-
// Construct result with or without columnData depending on columns value
|
|
284
|
-
// Block height = sum of per-line heights (variable when spans use different font sizes)
|
|
285
|
-
const blockHeight = richLines.reduce((sum, rl) => sum + rl.lineHeight, 0);
|
|
286
|
-
if (columnData) {
|
|
287
|
-
return {
|
|
288
|
-
element,
|
|
289
|
-
height: blockHeight, // richLines already have per-line heights
|
|
290
|
-
lines,
|
|
291
|
-
fontSize,
|
|
292
|
-
lineHeight,
|
|
293
|
-
fontKey: buildFontKey(doc.defaultFont ?? 'Inter', 400, 'normal'),
|
|
294
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
295
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
296
|
-
richLines,
|
|
297
|
-
columnData,
|
|
298
|
-
isRTL,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
return {
|
|
303
|
-
element,
|
|
304
|
-
height: blockHeight, // richLines already have per-line heights
|
|
305
|
-
lines,
|
|
306
|
-
fontSize,
|
|
307
|
-
lineHeight,
|
|
308
|
-
fontKey: buildFontKey(doc.defaultFont ?? 'Inter', 400, 'normal'),
|
|
309
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
310
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
311
|
-
richLines,
|
|
312
|
-
isRTL,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
case 'code': {
|
|
317
|
-
const fontSize = element.fontSize ?? Math.max(baseFontSize - 2, 8);
|
|
318
|
-
const lineHeight = element.lineHeight ?? (fontSize * LINE_HEIGHT_COMPACT);
|
|
319
|
-
const padding = element.padding ?? 8;
|
|
320
|
-
// Text area is narrower by padding on both sides
|
|
321
|
-
const textWidth = contentWidth - 2 * padding;
|
|
322
|
-
// Code blocks: never hyphenate — breaks would corrupt source code meaning
|
|
323
|
-
// Code blocks: always measure in logical (LTR) order — reordering breaks syntax
|
|
324
|
-
const lines = await measureText(element.text, fontSize, element.fontFamily, 400, Math.max(textWidth, 1), lineHeight);
|
|
325
|
-
// height = lines * lineHeight + padding top + padding bottom
|
|
326
|
-
const height = (lines.length || 1) * lineHeight + 2 * padding;
|
|
327
|
-
// Syntax highlighting: tokenize if language is set
|
|
328
|
-
let codeHighlightTokens;
|
|
329
|
-
if (element.language) {
|
|
330
|
-
codeHighlightTokens = await tokenizeCodeForHighlighting(element.text, element.language, element.color ?? '#24292f', lines.length, element.highlightTheme);
|
|
331
|
-
}
|
|
332
|
-
return {
|
|
333
|
-
element,
|
|
334
|
-
height,
|
|
335
|
-
lines,
|
|
336
|
-
fontSize,
|
|
337
|
-
lineHeight,
|
|
338
|
-
fontKey: buildFontKey(element.fontFamily, 400, 'normal'),
|
|
339
|
-
spaceAfter: element.spaceAfter ?? 12,
|
|
340
|
-
spaceBefore: element.spaceBefore ?? 12,
|
|
341
|
-
codePadding: padding,
|
|
342
|
-
...(codeHighlightTokens ? { codeHighlightTokens } : {}),
|
|
343
|
-
isRTL: false, // Code blocks always LTR
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
case 'blockquote': {
|
|
347
|
-
// Detect and reorder RTL text
|
|
348
|
-
const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
|
|
349
|
-
const fontSize = element.fontSize ?? baseFontSize;
|
|
350
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
351
|
-
const fontFamily = element.fontFamily ?? baseFont;
|
|
352
|
-
const fontWeight = element.fontWeight ?? 400;
|
|
353
|
-
const fontStyle = element.fontStyle ?? 'normal';
|
|
354
|
-
const fontKey = buildFontKey(fontFamily, fontWeight, fontStyle);
|
|
355
|
-
const borderWidth = element.borderWidth ?? 3;
|
|
356
|
-
const paddingH = element.paddingH ?? element.padding ?? 16;
|
|
357
|
-
const paddingV = element.paddingV ?? element.padding ?? 10;
|
|
358
|
-
// Text area excludes left border + horizontal padding on both sides
|
|
359
|
-
const textWidth = contentWidth - borderWidth - 2 * paddingH;
|
|
360
|
-
// Measure post-reorder (visual-order) text because the renderer draws characters in
|
|
361
|
-
// visual order; measuring the logical string for an RTL run would pick break points
|
|
362
|
-
// that don't match what is actually drawn, producing wrong line widths.
|
|
363
|
-
const lines = await measureText(visualText, fontSize, fontFamily, fontWeight, Math.max(textWidth, 1), lineHeight, hyphenatorOpts);
|
|
364
|
-
// height = lines * lineHeight + padding top + padding bottom
|
|
365
|
-
const height = (lines.length || 1) * lineHeight + 2 * paddingV;
|
|
366
|
-
return {
|
|
367
|
-
element,
|
|
368
|
-
height,
|
|
369
|
-
lines,
|
|
370
|
-
fontSize,
|
|
371
|
-
lineHeight,
|
|
372
|
-
fontKey,
|
|
373
|
-
spaceAfter: element.spaceAfter ?? 12,
|
|
374
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
375
|
-
blockquotePaddingV: paddingV,
|
|
376
|
-
blockquotePaddingH: paddingH,
|
|
377
|
-
blockquoteBorderWidth: borderWidth,
|
|
378
|
-
isRTL,
|
|
379
|
-
...(isRTL && { logicalText }), // NEW: Only store logical text when RTL
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
case 'callout': {
|
|
383
|
-
const el = element;
|
|
384
|
-
const fs = el.fontSize ?? baseFontSize;
|
|
385
|
-
const lh = el.lineHeight ?? (fs * LINE_HEIGHT_BODY);
|
|
386
|
-
const ph = el.paddingH ?? el.padding ?? 16;
|
|
387
|
-
const pv = el.paddingV ?? el.padding ?? 10;
|
|
388
|
-
const family = el.fontFamily ?? baseFont;
|
|
389
|
-
const colors = resolveCalloutColors(el.style);
|
|
390
|
-
const borderColor = el.borderColor ?? colors.border;
|
|
391
|
-
const backgroundColor = el.backgroundColor ?? colors.bg;
|
|
392
|
-
const color = el.color ?? '#1F2937';
|
|
393
|
-
const titleColor = el.titleColor ?? borderColor;
|
|
394
|
-
// Measure title height (one line assumed, bold)
|
|
395
|
-
let titleHeight = 0;
|
|
396
|
-
if (el.title) {
|
|
397
|
-
titleHeight = fs * LINE_HEIGHT_COMPACT + 4; // compact line height + 4pt separator
|
|
398
|
-
}
|
|
399
|
-
// Measure content text
|
|
400
|
-
const innerWidth = contentWidth - ph * 2;
|
|
401
|
-
const lines = await measureText(el.content, fs, family, el.fontWeight ?? 400, Math.max(innerWidth, 1), lh, hyphenatorOpts);
|
|
402
|
-
const contentTextHeight = lines.length * lh;
|
|
403
|
-
const totalHeight = pv + titleHeight + contentTextHeight + pv;
|
|
404
|
-
// Construct calloutData via a typed literal so TypeScript enforces the
|
|
405
|
-
// full contract at the producer site (every field present, correct type).
|
|
406
|
-
const calloutData = {
|
|
407
|
-
titleHeight, paddingH: ph, paddingV: pv,
|
|
408
|
-
borderColor, backgroundColor, titleColor, color,
|
|
409
|
-
...(el.title !== undefined ? { titleText: el.title } : {}),
|
|
410
|
-
};
|
|
411
|
-
return {
|
|
412
|
-
element,
|
|
413
|
-
height: totalHeight,
|
|
414
|
-
lines,
|
|
415
|
-
fontSize: fs,
|
|
416
|
-
lineHeight: lh,
|
|
417
|
-
fontKey: buildFontKey(family, el.fontWeight ?? 400, 'normal'),
|
|
418
|
-
spaceAfter: el.spaceAfter ?? 12,
|
|
419
|
-
spaceBefore: el.spaceBefore ?? 0,
|
|
420
|
-
calloutData,
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
case 'toc': {
|
|
424
|
-
// Placeholder: zero height. Will be replaced by actual TOC entries in two-pass mode.
|
|
425
|
-
return {
|
|
426
|
-
element,
|
|
427
|
-
height: 0,
|
|
428
|
-
lines: [],
|
|
429
|
-
fontSize: 0,
|
|
430
|
-
lineHeight: 0,
|
|
431
|
-
fontKey: '',
|
|
432
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
433
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
case 'toc-entry': {
|
|
437
|
-
// Internal type - should never be measured directly by user input
|
|
438
|
-
throw new PretextPdfError('VALIDATION_ERROR', 'toc-entry is an internal type and cannot be used in document content');
|
|
439
|
-
}
|
|
440
|
-
case 'footnote-def': {
|
|
441
|
-
const fn = element;
|
|
442
|
-
const baseFontSize = doc.defaultFontSize ?? 12;
|
|
443
|
-
const fontSize = fn.fontSize ?? Math.max(8, baseFontSize - 2);
|
|
444
|
-
const lineHeight = fontSize * LINE_HEIGHT_BODY;
|
|
445
|
-
const fontFamily = fn.fontFamily ?? doc.defaultFont ?? 'Inter';
|
|
446
|
-
const fontKey = buildFontKey(fontFamily, 400, 'normal');
|
|
447
|
-
// Measure the def text with a 20pt left indent (for the number prefix space)
|
|
448
|
-
const textLines = await measureText(fn.text, fontSize, fontFamily, 400, contentWidth - 20, // leave space for "N. " prefix
|
|
449
|
-
lineHeight, undefined);
|
|
450
|
-
const height = textLines.length * lineHeight;
|
|
451
|
-
return {
|
|
452
|
-
element,
|
|
453
|
-
height,
|
|
454
|
-
lines: textLines,
|
|
455
|
-
fontSize,
|
|
456
|
-
lineHeight,
|
|
457
|
-
fontKey,
|
|
458
|
-
spaceAfter: fn.spaceAfter ?? 4,
|
|
459
|
-
spaceBefore: 0,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
case 'float-group': {
|
|
463
|
-
// Float groups are handled at the measure.ts orchestrator level, not here
|
|
464
|
-
throw new PretextPdfError('VALIDATION_ERROR', 'float-group cannot be measured by measureBlock — it should be handled by measureAllBlocks in measure.ts');
|
|
465
|
-
}
|
|
466
|
-
default: {
|
|
467
|
-
const unknownType = element.type;
|
|
468
|
-
throw new PretextPdfError('VALIDATION_ERROR', `Unknown element type: "${String(unknownType)}"`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
// ─── HR is trivial (handled inline above) ────────────────────────────────────
|
|
473
|
-
// ─── Image measurement ────────────────────────────────────────────────────────
|
|
474
|
-
/** Measure an image element with its known imageMap key */
|
|
475
|
-
export async function measureImageWithKey(element, imageKey, imageMap, contentWidth, pageContentHeight) {
|
|
476
|
-
const pdfImage = imageMap.get(imageKey);
|
|
477
|
-
if (!pdfImage) {
|
|
478
|
-
throw new PretextPdfError('IMAGE_LOAD_FAILED', `Image "${imageKey}": not found in imageMap. This is an internal error — please report it.`);
|
|
479
|
-
}
|
|
480
|
-
// Natural dimensions (in original pixels — aspect ratio is what matters, not the pixel count)
|
|
481
|
-
const naturalWidth = pdfImage.width;
|
|
482
|
-
const naturalHeight = pdfImage.height;
|
|
483
|
-
// Resolve render dimensions (in pt)
|
|
484
|
-
let renderWidth;
|
|
485
|
-
let renderHeight;
|
|
486
|
-
if (element.width !== undefined && element.height !== undefined) {
|
|
487
|
-
// Both provided: use as-is
|
|
488
|
-
renderWidth = element.width;
|
|
489
|
-
renderHeight = element.height;
|
|
490
|
-
}
|
|
491
|
-
else if (element.width !== undefined) {
|
|
492
|
-
// Width only: scale height
|
|
493
|
-
renderWidth = element.width;
|
|
494
|
-
renderHeight = renderWidth * (naturalHeight / naturalWidth);
|
|
495
|
-
}
|
|
496
|
-
else if (element.height !== undefined) {
|
|
497
|
-
// Height only: scale width
|
|
498
|
-
renderHeight = element.height;
|
|
499
|
-
renderWidth = renderHeight * (naturalWidth / naturalHeight);
|
|
500
|
-
}
|
|
501
|
-
else {
|
|
502
|
-
// Neither: fit to content width
|
|
503
|
-
renderWidth = contentWidth;
|
|
504
|
-
renderHeight = renderWidth * (naturalHeight / naturalWidth);
|
|
505
|
-
}
|
|
506
|
-
// Clamp to content width
|
|
507
|
-
if (renderWidth > contentWidth) {
|
|
508
|
-
const scale = contentWidth / renderWidth;
|
|
509
|
-
renderWidth = contentWidth;
|
|
510
|
-
renderHeight = renderHeight * scale;
|
|
511
|
-
}
|
|
512
|
-
// Validate height doesn't exceed page
|
|
513
|
-
if (renderHeight > pageContentHeight) {
|
|
514
|
-
throw new PretextPdfError('IMAGE_TOO_TALL', `Image "${imageKey}" would render at ${renderHeight.toFixed(1)}pt tall, which exceeds the page content area (${pageContentHeight.toFixed(1)}pt). ` +
|
|
515
|
-
`Reduce 'height' or increase page size/reduce margins.`);
|
|
516
|
-
}
|
|
517
|
-
const imageData = {
|
|
518
|
-
imageKey,
|
|
519
|
-
renderWidth,
|
|
520
|
-
renderHeight,
|
|
521
|
-
align: element.align ?? 'left',
|
|
522
|
-
};
|
|
523
|
-
return {
|
|
524
|
-
element,
|
|
525
|
-
height: renderHeight,
|
|
526
|
-
lines: [],
|
|
527
|
-
fontSize: 0,
|
|
528
|
-
lineHeight: 0,
|
|
529
|
-
fontKey: '',
|
|
530
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
531
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
532
|
-
imageData,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
// ─── Float image block measurement ───────────────────────────────────────────
|
|
536
|
-
export async function measureFloatImageBlock(element, imageKey, imageMap, contentWidth, pageContentHeight, doc) {
|
|
537
|
-
const floatWidth = element.floatWidth ?? (contentWidth * 0.35);
|
|
538
|
-
const floatGap = element.floatGap ?? 12;
|
|
539
|
-
const textColWidth = contentWidth - floatWidth - floatGap;
|
|
540
|
-
if (textColWidth < 50) {
|
|
541
|
-
throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Float image: text column would be ${textColWidth.toFixed(1)}pt (minimum 50pt). ` +
|
|
542
|
-
`Reduce floatWidth or increase page width.`);
|
|
543
|
-
}
|
|
544
|
-
// Measure the image at floatWidth using a synthetic element
|
|
545
|
-
const syntheticEl = {
|
|
546
|
-
type: 'image',
|
|
547
|
-
src: element.src,
|
|
548
|
-
...(element.format !== undefined ? { format: element.format } : {}),
|
|
549
|
-
width: floatWidth,
|
|
550
|
-
align: 'left',
|
|
551
|
-
spaceAfter: 0,
|
|
552
|
-
spaceBefore: 0,
|
|
553
|
-
};
|
|
554
|
-
const imageBlock = await measureImageWithKey(syntheticEl, imageKey, imageMap, floatWidth, pageContentHeight);
|
|
555
|
-
const imageRenderWidth = imageBlock.imageData.renderWidth;
|
|
556
|
-
const imageRenderHeight = imageBlock.imageData.renderHeight;
|
|
557
|
-
// Measure the float text (plain or rich)
|
|
558
|
-
const fontSize = element.floatFontSize ?? doc.defaultFontSize ?? 12;
|
|
559
|
-
const lineHeight = fontSize * LINE_HEIGHT_BODY;
|
|
560
|
-
const fontFamily = element.floatFontFamily ?? doc.defaultFont ?? 'Inter';
|
|
561
|
-
const fontKey = buildFontKey(fontFamily, 400, 'normal');
|
|
562
|
-
let textLines = [];
|
|
563
|
-
let richFloatLines;
|
|
564
|
-
if (element.floatSpans) {
|
|
565
|
-
richFloatLines = await measureRichText(element.floatSpans, fontSize, lineHeight, textColWidth, 'left', doc);
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
textLines = await measureText(element.floatText, fontSize, fontFamily, 400, textColWidth, lineHeight, undefined);
|
|
569
|
-
}
|
|
570
|
-
// Column X positions
|
|
571
|
-
const imageColX = element.float === 'left' ? 0 : textColWidth + floatGap;
|
|
572
|
-
const textColX = element.float === 'left' ? floatWidth + floatGap : 0;
|
|
573
|
-
const textHeight = richFloatLines
|
|
574
|
-
? richFloatLines.reduce((sum, l) => sum + l.lineHeight, 0)
|
|
575
|
-
: textLines.length * lineHeight;
|
|
576
|
-
const compositeHeight = Math.max(imageRenderHeight, textHeight);
|
|
577
|
-
return {
|
|
578
|
-
element,
|
|
579
|
-
height: compositeHeight,
|
|
580
|
-
lines: [],
|
|
581
|
-
fontSize: 0,
|
|
582
|
-
lineHeight: 0,
|
|
583
|
-
fontKey: '',
|
|
584
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
585
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
586
|
-
floatData: {
|
|
587
|
-
imageKey,
|
|
588
|
-
imageRenderWidth,
|
|
589
|
-
imageRenderHeight,
|
|
590
|
-
imageColX,
|
|
591
|
-
textColX,
|
|
592
|
-
textColWidth,
|
|
593
|
-
textLines,
|
|
594
|
-
...(richFloatLines ? { richFloatLines } : {}),
|
|
595
|
-
textFontKey: fontKey,
|
|
596
|
-
textFontSize: fontSize,
|
|
597
|
-
textLineHeight: lineHeight,
|
|
598
|
-
textColor: element.floatColor ?? '#000000',
|
|
599
|
-
},
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
// ─── Float group measurement (multi-paragraph float) ────────────────────────────
|
|
603
|
-
export async function measureFloatGroup(element, imageKey, imageMap, contentWidth, pageContentHeight, doc, hyphenatorOpts) {
|
|
604
|
-
const floatWidth = element.floatWidth ?? (contentWidth * 0.35);
|
|
605
|
-
const floatGap = element.floatGap ?? 12;
|
|
606
|
-
const textColWidth = contentWidth - floatWidth - floatGap;
|
|
607
|
-
if (textColWidth < 50) {
|
|
608
|
-
throw new PretextPdfError('COLUMN_WIDTH_TOO_NARROW', `Float group: text column would be ${textColWidth.toFixed(1)}pt (minimum 50pt). ` +
|
|
609
|
-
`Reduce floatWidth or increase page width.`);
|
|
610
|
-
}
|
|
611
|
-
// Measure the image at floatWidth
|
|
612
|
-
const syntheticImageEl = {
|
|
613
|
-
type: 'image',
|
|
614
|
-
src: '',
|
|
615
|
-
...(element.image.height !== undefined ? { height: element.image.height } : {}),
|
|
616
|
-
align: 'left',
|
|
617
|
-
spaceAfter: 0,
|
|
618
|
-
spaceBefore: 0,
|
|
619
|
-
};
|
|
620
|
-
const imageBlock = await measureImageWithKey(syntheticImageEl, imageKey, imageMap, floatWidth, pageContentHeight);
|
|
621
|
-
const imageRenderWidth = imageBlock.imageData.renderWidth;
|
|
622
|
-
const imageRenderHeight = imageBlock.imageData.renderHeight;
|
|
623
|
-
// Measure each content element in the text column
|
|
624
|
-
const baseFontSize = doc.defaultFontSize ?? 12;
|
|
625
|
-
const textItems = [];
|
|
626
|
-
let totalTextHeight = 0;
|
|
627
|
-
for (const contentEl of element.content) {
|
|
628
|
-
const measuredEl = await measureBlock(contentEl, textColWidth, doc, hyphenatorOpts);
|
|
629
|
-
// Handle arrays (lists return MeasuredBlock[])
|
|
630
|
-
const blocks = Array.isArray(measuredEl) ? measuredEl : [measuredEl];
|
|
631
|
-
for (const block of blocks) {
|
|
632
|
-
const fontSize = block.fontSize || baseFontSize;
|
|
633
|
-
const lineHeight = block.lineHeight || (fontSize * LINE_HEIGHT_BODY);
|
|
634
|
-
// Extract text from lines or rich-lines
|
|
635
|
-
let lines = [];
|
|
636
|
-
if (block.richLines && block.richLines.length > 0) {
|
|
637
|
-
// Rich paragraph: extract plain text from rich lines (plain-text fallback)
|
|
638
|
-
lines = block.richLines.map(rl => ({
|
|
639
|
-
text: rl.fragments.map(f => f.text).join(''),
|
|
640
|
-
width: rl.totalWidth,
|
|
641
|
-
}));
|
|
642
|
-
}
|
|
643
|
-
else {
|
|
644
|
-
lines = block.lines;
|
|
645
|
-
}
|
|
646
|
-
const yOffsetFromTop = totalTextHeight;
|
|
647
|
-
const item = {
|
|
648
|
-
lines,
|
|
649
|
-
fontSize: block.fontSize,
|
|
650
|
-
lineHeight: block.lineHeight,
|
|
651
|
-
fontKey: block.fontKey,
|
|
652
|
-
fontWeight: 400,
|
|
653
|
-
spaceAfter: block.spaceAfter,
|
|
654
|
-
yOffsetFromTop,
|
|
655
|
-
};
|
|
656
|
-
if (block.richLines) {
|
|
657
|
-
item.richLines = block.richLines;
|
|
658
|
-
}
|
|
659
|
-
textItems.push(item);
|
|
660
|
-
totalTextHeight += block.height + block.spaceAfter;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
// Remove trailing spaceAfter (it's not needed at the end)
|
|
664
|
-
if (textItems.length > 0) {
|
|
665
|
-
totalTextHeight -= textItems[textItems.length - 1].spaceAfter;
|
|
666
|
-
}
|
|
667
|
-
// Column X positions
|
|
668
|
-
const imageColX = element.float === 'left' ? 0 : textColWidth + floatGap;
|
|
669
|
-
const textColX = element.float === 'left' ? floatWidth + floatGap : 0;
|
|
670
|
-
const compositeHeight = Math.max(imageRenderHeight, totalTextHeight);
|
|
671
|
-
return {
|
|
672
|
-
element,
|
|
673
|
-
height: compositeHeight,
|
|
674
|
-
lines: [],
|
|
675
|
-
fontSize: 0,
|
|
676
|
-
lineHeight: 0,
|
|
677
|
-
fontKey: '',
|
|
678
|
-
spaceAfter: element.spaceAfter ?? 12,
|
|
679
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
680
|
-
floatGroupData: {
|
|
681
|
-
imageKey,
|
|
682
|
-
imageRenderWidth,
|
|
683
|
-
imageRenderHeight,
|
|
684
|
-
imageColX,
|
|
685
|
-
textColX,
|
|
686
|
-
textColWidth,
|
|
687
|
-
textItems,
|
|
688
|
-
totalTextHeight,
|
|
689
|
-
},
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
// ─── List measurement (returns MeasuredBlock[]) ───────────────────────────────
|
|
693
|
-
async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
|
|
694
|
-
const baseFontFamily = doc.defaultFont ?? 'Inter';
|
|
695
|
-
const fontSize = element.fontSize ?? baseFontSize;
|
|
696
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
697
|
-
const indent = element.indent ?? 20;
|
|
698
|
-
const itemSpaceAfter = element.itemSpaceAfter ?? 4;
|
|
699
|
-
const fontKey = buildFontKey(baseFontFamily, 400, 'normal');
|
|
700
|
-
const blocks = [];
|
|
701
|
-
// Flatten items and nested items (up to 2 levels deep)
|
|
702
|
-
const nestedStyle = element.nestedNumberingStyle ?? 'continue';
|
|
703
|
-
let orderedIndex = 1;
|
|
704
|
-
const allItems = [];
|
|
705
|
-
for (let i = 0; i < element.items.length; i++) {
|
|
706
|
-
const item = element.items[i];
|
|
707
|
-
const isFirst = i === 0;
|
|
708
|
-
const marker = element.style === 'ordered'
|
|
709
|
-
? `${orderedIndex}.`
|
|
710
|
-
: (element.marker ?? '•');
|
|
711
|
-
orderedIndex++;
|
|
712
|
-
allItems.push({ text: item.text, marker, depth: 0, isFirstInList: isFirst, fontWeight: item.fontWeight ?? 400 });
|
|
713
|
-
// Nested items (depth 1)
|
|
714
|
-
if (item.items && item.items.length > 0) {
|
|
715
|
-
// 'restart': nested ordered items count from 1, parent counter unaffected
|
|
716
|
-
// 'continue': nested items share the parent counter (existing behavior)
|
|
717
|
-
let nestedIndex = nestedStyle === 'restart' ? 1 : orderedIndex;
|
|
718
|
-
for (let ni = 0; ni < item.items.length; ni++) {
|
|
719
|
-
const nested = item.items[ni];
|
|
720
|
-
const nestedMarker = element.style === 'ordered'
|
|
721
|
-
? `${nestedIndex}.`
|
|
722
|
-
: '◦'; // hollow bullet for depth-1 unordered
|
|
723
|
-
nestedIndex++;
|
|
724
|
-
if (nestedStyle === 'continue')
|
|
725
|
-
orderedIndex++;
|
|
726
|
-
allItems.push({ text: nested.text, marker: nestedMarker, depth: 1, isFirstInList: false, fontWeight: nested.fontWeight ?? 400 });
|
|
727
|
-
// Nested items (depth 2)
|
|
728
|
-
if (nested.items && nested.items.length > 0) {
|
|
729
|
-
let deepIndex = nestedStyle === 'restart' ? 1 : nestedIndex;
|
|
730
|
-
for (let di = 0; di < nested.items.length; di++) {
|
|
731
|
-
const deep = nested.items[di];
|
|
732
|
-
const deepMarker = element.style === 'ordered'
|
|
733
|
-
? `${deepIndex}.`
|
|
734
|
-
: '▪'; // small filled square for depth-2 unordered
|
|
735
|
-
deepIndex++;
|
|
736
|
-
if (nestedStyle === 'continue')
|
|
737
|
-
orderedIndex++;
|
|
738
|
-
allItems.push({ text: deep.text, marker: deepMarker, depth: 2, isFirstInList: false, fontWeight: deep.fontWeight ?? 400 });
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
// Compute markerWidth: use explicit override if set, otherwise measure widest marker
|
|
745
|
-
let markerWidth;
|
|
746
|
-
if (element.markerWidth != null) {
|
|
747
|
-
markerWidth = element.markerWidth;
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
const widestMarker = element.style === 'ordered'
|
|
751
|
-
? `${allItems.length}.`
|
|
752
|
-
: (element.marker ?? '•');
|
|
753
|
-
const measured = await measureNaturalTextWidth(widestMarker, fontSize, baseFontFamily, 400);
|
|
754
|
-
markerWidth = Math.max(16, measured + 6);
|
|
755
|
-
}
|
|
756
|
-
// Width available for item text (after indent + marker column)
|
|
757
|
-
const textWidth = contentWidth - indent - markerWidth;
|
|
758
|
-
if (textWidth <= 0) {
|
|
759
|
-
throw new PretextPdfError('VALIDATION_ERROR', `List indent (${indent}pt) + markerWidth (${markerWidth}pt) exceeds contentWidth (${contentWidth}pt). Reduce indent or markerWidth.`);
|
|
760
|
-
}
|
|
761
|
-
for (let i = 0; i < allItems.length; i++) {
|
|
762
|
-
const item = allItems[i];
|
|
763
|
-
const isLast = i === allItems.length - 1;
|
|
764
|
-
const nestedIndent = indent + item.depth * markerWidth;
|
|
765
|
-
const nestedTextWidth = textWidth - item.depth * markerWidth;
|
|
766
|
-
const lines = await measureText(item.text, fontSize, baseFontFamily, item.fontWeight, nestedTextWidth, lineHeight, hyphenatorOpts);
|
|
767
|
-
const listItemData = {
|
|
768
|
-
marker: item.marker,
|
|
769
|
-
indent: nestedIndent,
|
|
770
|
-
markerWidth,
|
|
771
|
-
color: element.color ?? '#000000',
|
|
772
|
-
fontWeight: item.fontWeight,
|
|
773
|
-
};
|
|
774
|
-
// spaceBefore: only the first item in the entire list gets the list's spaceBefore
|
|
775
|
-
// spaceAfter: itemSpaceAfter between items, list.spaceAfter on the last item
|
|
776
|
-
const spaceBefore = item.isFirstInList ? (element.spaceBefore ?? 0) : 0;
|
|
777
|
-
const spaceAfter = isLast ? (element.spaceAfter ?? 0) : itemSpaceAfter;
|
|
778
|
-
blocks.push({
|
|
779
|
-
element, // All items share the parent ListElement (for type checking in renderer)
|
|
780
|
-
height: Math.max(lines.length, 1) * lineHeight,
|
|
781
|
-
lines,
|
|
782
|
-
fontSize,
|
|
783
|
-
lineHeight,
|
|
784
|
-
fontKey,
|
|
785
|
-
spaceAfter,
|
|
786
|
-
spaceBefore,
|
|
787
|
-
listItemData,
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
return blocks;
|
|
791
|
-
}
|
|
792
|
-
/** Build a map from "rowIdx,colIdx" → span origin info for all positions occupied by a rowspan cell from an earlier row. */
|
|
793
|
-
function buildSpanGrid(rows) {
|
|
794
|
-
const grid = new Map();
|
|
795
|
-
for (let ri = 0; ri < rows.length; ri++) {
|
|
796
|
-
let ci = 0;
|
|
797
|
-
for (const cell of rows[ri].cells) {
|
|
798
|
-
while (grid.has(`${ri},${ci}`))
|
|
799
|
-
ci++;
|
|
800
|
-
const cs = cell.colspan ?? 1;
|
|
801
|
-
const rs = cell.rowspan ?? 1;
|
|
802
|
-
for (let r2 = ri + 1; r2 < ri + rs; r2++) {
|
|
803
|
-
for (let c2 = ci; c2 < ci + cs; c2++) {
|
|
804
|
-
grid.set(`${r2},${c2}`, { originRowIdx: ri, originColStart: ci, colspan: cs, rowspan: rs });
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
ci += cs;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return grid;
|
|
811
|
-
}
|
|
812
|
-
async function measureTable(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
|
|
813
|
-
const baseFontFamily = doc.defaultFont ?? 'Inter';
|
|
814
|
-
const fontSize = element.fontSize ?? baseFontSize;
|
|
815
|
-
const lineHeight = doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
816
|
-
const cellPaddingH = element.cellPaddingH ?? 8;
|
|
817
|
-
const cellPaddingV = element.cellPaddingV ?? 6;
|
|
818
|
-
const borderWidth = element.borderWidth ?? 0.5;
|
|
819
|
-
const borderColor = element.borderColor ?? '#cccccc';
|
|
820
|
-
const headerBgColor = element.headerBgColor ?? '#f5f5f5';
|
|
821
|
-
// Build span occupancy grid (needed for correct colStart tracking in all passes)
|
|
822
|
-
const spanGrid = buildSpanGrid(element.rows);
|
|
823
|
-
// Pre-pass: measure natural widths for 'auto' columns — run all in parallel
|
|
824
|
-
const hasAutoColumns = element.columns.some(c => c.width === 'auto');
|
|
825
|
-
let naturalWidths;
|
|
826
|
-
if (hasAutoColumns) {
|
|
827
|
-
naturalWidths = new Array(element.columns.length).fill(0);
|
|
828
|
-
const jobs = [];
|
|
829
|
-
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
830
|
-
const row = element.rows[rowIdx];
|
|
831
|
-
let colIdx = 0;
|
|
832
|
-
for (const cell of row.cells) {
|
|
833
|
-
while (spanGrid.has(`${rowIdx},${colIdx}`))
|
|
834
|
-
colIdx++;
|
|
835
|
-
const cs = cell.colspan ?? 1;
|
|
836
|
-
if (element.columns[colIdx]?.width === 'auto') {
|
|
837
|
-
jobs.push({
|
|
838
|
-
colIdx, cs,
|
|
839
|
-
fontWeight: (cell.fontWeight ?? (row.isHeader ? 700 : 400)),
|
|
840
|
-
cellFontSize: cell.fontSize ?? fontSize,
|
|
841
|
-
cellFamily: cell.fontFamily ?? baseFontFamily,
|
|
842
|
-
text: cell.text,
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
colIdx += cs;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
// Measure all auto cells in parallel
|
|
849
|
-
const widths = await Promise.all(jobs.map(j => measureNaturalTextWidth(j.text, j.cellFontSize, j.cellFamily, j.fontWeight)));
|
|
850
|
-
// Assign results back
|
|
851
|
-
for (let i = 0; i < jobs.length; i++) {
|
|
852
|
-
const { colIdx, cs } = jobs[i];
|
|
853
|
-
const cellNaturalWidth = widths[i] + 2 * cellPaddingH;
|
|
854
|
-
const perColumn = cellNaturalWidth / cs;
|
|
855
|
-
for (let si = colIdx; si < colIdx + cs && si < element.columns.length; si++) {
|
|
856
|
-
if (element.columns[si]?.width === 'auto') {
|
|
857
|
-
naturalWidths[si] = Math.max(naturalWidths[si], perColumn);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
// Resolve column widths (passes naturalWidths for 'auto' columns)
|
|
863
|
-
const columnWidths = resolveColumnWidths(element.columns, contentWidth, cellPaddingH, borderWidth, naturalWidths);
|
|
864
|
-
// Determine header row count
|
|
865
|
-
const headerRowCount = element.headerRows !== undefined
|
|
866
|
-
? element.headerRows
|
|
867
|
-
: element.rows.filter(r => r.isHeader).length;
|
|
868
|
-
const allCellMeta = [];
|
|
869
|
-
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
870
|
-
const row = element.rows[rowIdx];
|
|
871
|
-
let colStart = 0;
|
|
872
|
-
for (const cell of row.cells) {
|
|
873
|
-
while (spanGrid.has(`${rowIdx},${colStart}`))
|
|
874
|
-
colStart++;
|
|
875
|
-
const cs = cell.colspan ?? 1;
|
|
876
|
-
const rs = cell.rowspan ?? 1;
|
|
877
|
-
const col = element.columns[colStart];
|
|
878
|
-
let mergedWidth = 0;
|
|
879
|
-
for (let si = colStart; si < colStart + cs && si < columnWidths.length; si++) {
|
|
880
|
-
mergedWidth += columnWidths[si];
|
|
881
|
-
if (si < colStart + cs - 1)
|
|
882
|
-
mergedWidth += borderWidth;
|
|
883
|
-
}
|
|
884
|
-
const fontWeight = (cell.fontWeight ?? (row.isHeader ? 700 : 400));
|
|
885
|
-
const fontFamily = cell.fontFamily ?? baseFontFamily;
|
|
886
|
-
const cellFontSize = cell.fontSize ?? fontSize;
|
|
887
|
-
const cellLineHeight = doc.defaultLineHeight ?? (cellFontSize * LINE_HEIGHT_BODY);
|
|
888
|
-
const cellFontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
889
|
-
const textWidth = mergedWidth - 2 * cellPaddingH - borderWidth;
|
|
890
|
-
const cellDir = (cell.dir ?? element.dir ?? 'auto');
|
|
891
|
-
allCellMeta.push({ cell, row, rowIdx, colStart, cs, rs, col, mergedWidth, fontWeight, fontFamily, cellFontSize, cellLineHeight, cellFontKey, textWidth, cellDir });
|
|
892
|
-
colStart += cs;
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
// Step 2: Run all RTL detection + text measurement in parallel across the whole table
|
|
896
|
-
const cellResults = await Promise.all(allCellMeta.map(async (m) => {
|
|
897
|
-
const { visual: cellVisualText, isRTL: cellIsRTL } = await detectAndReorderRTL(m.cell.text, m.cellDir);
|
|
898
|
-
const lines = await measureText(cellVisualText, m.cellFontSize, m.fontFamily, m.fontWeight, Math.max(m.textWidth, 1), m.cellLineHeight, hyphenatorOpts);
|
|
899
|
-
return { cellVisualText, cellIsRTL, lines };
|
|
900
|
-
}));
|
|
901
|
-
const cellByKey = new Map();
|
|
902
|
-
for (let i = 0; i < allCellMeta.length; i++) {
|
|
903
|
-
const m = allCellMeta[i];
|
|
904
|
-
cellByKey.set(`${m.rowIdx},${m.colStart}`, { meta: m, result: cellResults[i] });
|
|
905
|
-
}
|
|
906
|
-
// Sub-pass 3a: Compute raw row heights from non-spanning cells only
|
|
907
|
-
const rowHeights = new Array(element.rows.length).fill(0);
|
|
908
|
-
for (const { meta: m, result } of cellByKey.values()) {
|
|
909
|
-
if (m.rs === 1) {
|
|
910
|
-
const cellContentHeight = Math.max(result.lines.length, 1) * m.cellLineHeight;
|
|
911
|
-
rowHeights[m.rowIdx] = Math.max(rowHeights[m.rowIdx], cellContentHeight);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
for (let ri = 0; ri < rowHeights.length; ri++) {
|
|
915
|
-
rowHeights[ri] = (rowHeights[ri] ?? 0) + 2 * cellPaddingV;
|
|
916
|
-
}
|
|
917
|
-
// Sub-pass 3b: Expand last spanned row if spanning cell needs more space
|
|
918
|
-
for (const { meta: m, result } of cellByKey.values()) {
|
|
919
|
-
if (m.rs > 1) {
|
|
920
|
-
const cellContentHeight = Math.max(result.lines.length, 1) * m.cellLineHeight + 2 * cellPaddingV;
|
|
921
|
-
let spanHeight = 0;
|
|
922
|
-
for (let r2 = m.rowIdx; r2 < m.rowIdx + m.rs && r2 < rowHeights.length; r2++) {
|
|
923
|
-
spanHeight += rowHeights[r2];
|
|
924
|
-
}
|
|
925
|
-
if (cellContentHeight > spanHeight) {
|
|
926
|
-
const lastRowIdx = Math.min(m.rowIdx + m.rs - 1, rowHeights.length - 1);
|
|
927
|
-
rowHeights[lastRowIdx] += cellContentHeight - spanHeight;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
// Sub-pass 3c: Build measuredRows, inserting placeholder cells for rowspan continuations
|
|
932
|
-
const measuredRows = [];
|
|
933
|
-
const numColumns = element.columns.length;
|
|
934
|
-
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
935
|
-
const row = element.rows[rowIdx];
|
|
936
|
-
const rowHeight = rowHeights[rowIdx];
|
|
937
|
-
const measuredCells = [];
|
|
938
|
-
let hasRowspan = false;
|
|
939
|
-
let colCursor = 0;
|
|
940
|
-
while (colCursor < numColumns) {
|
|
941
|
-
const spanEntry = spanGrid.get(`${rowIdx},${colCursor}`);
|
|
942
|
-
if (spanEntry) {
|
|
943
|
-
// Only insert ONE placeholder per span group (at the leftmost column of the span)
|
|
944
|
-
if (colCursor === spanEntry.originColStart) {
|
|
945
|
-
const originCell = cellByKey.get(`${spanEntry.originRowIdx},${spanEntry.originColStart}`);
|
|
946
|
-
const pw = originCell?.meta.mergedWidth ?? (columnWidths[colCursor] ?? 0);
|
|
947
|
-
measuredCells.push({
|
|
948
|
-
lines: [], fontSize: 0, lineHeight: 0, fontKey: '', fontFamily: '',
|
|
949
|
-
align: 'left', color: '#000000',
|
|
950
|
-
colspan: spanEntry.colspan, mergedWidth: pw,
|
|
951
|
-
isSpanPlaceholder: true,
|
|
952
|
-
});
|
|
953
|
-
colCursor += spanEntry.colspan;
|
|
954
|
-
}
|
|
955
|
-
else {
|
|
956
|
-
// Mid-span column not at origin — advance past the full span group
|
|
957
|
-
colCursor += spanEntry.colspan;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
else {
|
|
961
|
-
const cellAtCol = cellByKey.get(`${rowIdx},${colCursor}`);
|
|
962
|
-
if (!cellAtCol) {
|
|
963
|
-
colCursor++;
|
|
964
|
-
continue;
|
|
965
|
-
}
|
|
966
|
-
const { meta: m, result: { cellIsRTL, lines } } = cellAtCol;
|
|
967
|
-
let spanHeight;
|
|
968
|
-
if (m.rs > 1) {
|
|
969
|
-
spanHeight = 0;
|
|
970
|
-
for (let r2 = rowIdx; r2 < rowIdx + m.rs && r2 < rowHeights.length; r2++) {
|
|
971
|
-
spanHeight += rowHeights[r2];
|
|
972
|
-
}
|
|
973
|
-
hasRowspan = true;
|
|
974
|
-
}
|
|
975
|
-
const align = m.cell.align ?? m.col.align ?? (cellIsRTL ? 'right' : 'left');
|
|
976
|
-
const measuredCell = {
|
|
977
|
-
lines,
|
|
978
|
-
fontSize: m.cellFontSize,
|
|
979
|
-
lineHeight: m.cellLineHeight,
|
|
980
|
-
fontKey: m.cellFontKey,
|
|
981
|
-
fontFamily: m.fontFamily,
|
|
982
|
-
align,
|
|
983
|
-
color: m.cell.color ?? '#000000',
|
|
984
|
-
colspan: m.cs,
|
|
985
|
-
mergedWidth: m.mergedWidth,
|
|
986
|
-
isRTL: cellIsRTL,
|
|
987
|
-
...(m.cell.tabularNumbers !== undefined && { tabularNumbers: m.cell.tabularNumbers }),
|
|
988
|
-
...(m.rs > 1 ? { rowspan: m.rs } : {}),
|
|
989
|
-
...(spanHeight !== undefined ? { spanHeight } : {}),
|
|
990
|
-
};
|
|
991
|
-
if (m.cell.bgColor !== undefined)
|
|
992
|
-
measuredCell.bgColor = m.cell.bgColor;
|
|
993
|
-
measuredCells.push(measuredCell);
|
|
994
|
-
colCursor += m.cs;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
const activeBoundaries = computeActiveBoundaries(measuredCells, numColumns);
|
|
998
|
-
measuredRows.push({
|
|
999
|
-
cells: measuredCells,
|
|
1000
|
-
height: rowHeight,
|
|
1001
|
-
isHeader: row.isHeader ?? false,
|
|
1002
|
-
activeBoundaries,
|
|
1003
|
-
...(hasRowspan ? { hasRowspan: true } : {}),
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
// Header rows are the first N rows
|
|
1007
|
-
const headerRows = measuredRows.slice(0, headerRowCount);
|
|
1008
|
-
const headerRowHeight = headerRows.reduce((sum, r) => sum + r.height, 0);
|
|
1009
|
-
// Total table height = sum of all row heights
|
|
1010
|
-
const totalHeight = measuredRows.reduce((sum, r) => sum + r.height, 0);
|
|
1011
|
-
const tableData = {
|
|
1012
|
-
columnWidths,
|
|
1013
|
-
rows: measuredRows,
|
|
1014
|
-
headerRowCount,
|
|
1015
|
-
headerRowHeight,
|
|
1016
|
-
cellPaddingH,
|
|
1017
|
-
cellPaddingV,
|
|
1018
|
-
borderWidth,
|
|
1019
|
-
borderColor,
|
|
1020
|
-
headerBgColor,
|
|
1021
|
-
};
|
|
1022
|
-
return {
|
|
1023
|
-
element,
|
|
1024
|
-
height: totalHeight,
|
|
1025
|
-
lines: [],
|
|
1026
|
-
fontSize: 0,
|
|
1027
|
-
lineHeight: 0,
|
|
1028
|
-
fontKey: '',
|
|
1029
|
-
spaceAfter: element.spaceAfter ?? 0,
|
|
1030
|
-
spaceBefore: element.spaceBefore ?? 0,
|
|
1031
|
-
tableData,
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
1034
|
-
// ─── Column width resolution ──────────────────────────────────────────────────
|
|
1035
|
-
/**
|
|
1036
|
-
* Resolve column width definitions to concrete pt values.
|
|
1037
|
-
* Fixed widths are used as-is. Star widths ('2*', '*') share the remaining space.
|
|
1038
|
-
* 'auto' columns use naturalWidths[i] (measured content width) — caller must pre-compute these.
|
|
1039
|
-
*
|
|
1040
|
-
* naturalWidths is required if any column uses 'auto'. It maps column index → natural text width in pt
|
|
1041
|
-
* (the minimum width needed to display cell text on one line, including cellPaddingH on both sides).
|
|
1042
|
-
*/
|
|
1043
|
-
export function resolveColumnWidths(columns, contentWidth, cellPaddingH, borderWidth, naturalWidths) {
|
|
1044
|
-
const MIN_COLUMN_WIDTH = cellPaddingH * 2 + borderWidth * 2 + 4; // minimum usable pt
|
|
1045
|
-
let totalFixed = 0;
|
|
1046
|
-
let totalStars = 0;
|
|
1047
|
-
let totalAutoNatural = 0;
|
|
1048
|
-
let autoCount = 0;
|
|
1049
|
-
for (let i = 0; i < columns.length; i++) {
|
|
1050
|
-
const col = columns[i];
|
|
1051
|
-
if (typeof col.width === 'number') {
|
|
1052
|
-
totalFixed += col.width;
|
|
1053
|
-
}
|
|
1054
|
-
else if (col.width === 'auto') {
|
|
1055
|
-
// Auto columns reserve their natural width from remaining space
|
|
1056
|
-
const natural = naturalWidths?.[i] ?? MIN_COLUMN_WIDTH;
|
|
1057
|
-
totalAutoNatural += natural;
|
|
1058
|
-
autoCount++;
|
|
1059
|
-
}
|
|
1060
|
-
else {
|
|
1061
|
-
// '*' → 1 star, '2*' → 2 stars, '1.5*' → 1.5 stars
|
|
1062
|
-
const match = col.width.match(/^(\d*\.?\d*)?\*$/);
|
|
1063
|
-
const stars = (match && match[1]) ? parseFloat(match[1]) : 1;
|
|
1064
|
-
totalStars += stars;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
const remaining = contentWidth - totalFixed;
|
|
1068
|
-
if (remaining < -0.01) {
|
|
1069
|
-
throw new PretextPdfError('TABLE_COLUMN_OVERFLOW', `Table fixed column widths (${totalFixed.toFixed(1)}pt) exceed content width (${contentWidth.toFixed(1)}pt). ` +
|
|
1070
|
-
`Reduce column widths or page margins.`);
|
|
1071
|
-
}
|
|
1072
|
-
// How much space is available after fixed columns
|
|
1073
|
-
const availableForFlexible = Math.max(0, remaining);
|
|
1074
|
-
// Auto columns claim their natural width (capped at available space).
|
|
1075
|
-
// Star columns share whatever remains after auto columns.
|
|
1076
|
-
// If auto columns overflow, they get proportional shares of available space.
|
|
1077
|
-
const autoFits = totalAutoNatural <= availableForFlexible;
|
|
1078
|
-
const autoUsed = autoFits ? totalAutoNatural : availableForFlexible;
|
|
1079
|
-
const availableForStars = availableForFlexible - autoUsed;
|
|
1080
|
-
const starUnit = totalStars > 0 ? Math.max(0, availableForStars) / totalStars : 0;
|
|
1081
|
-
return columns.map((col, i) => {
|
|
1082
|
-
let resolved;
|
|
1083
|
-
if (typeof col.width === 'number') {
|
|
1084
|
-
resolved = col.width;
|
|
1085
|
-
}
|
|
1086
|
-
else if (col.width === 'auto') {
|
|
1087
|
-
const natural = naturalWidths?.[i] ?? MIN_COLUMN_WIDTH;
|
|
1088
|
-
if (autoFits) {
|
|
1089
|
-
resolved = natural;
|
|
1090
|
-
}
|
|
1091
|
-
else {
|
|
1092
|
-
// Constrained: proportional share based on natural widths
|
|
1093
|
-
resolved = totalAutoNatural > 0
|
|
1094
|
-
? (natural / totalAutoNatural) * availableForFlexible
|
|
1095
|
-
: MIN_COLUMN_WIDTH;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
else {
|
|
1099
|
-
const match = col.width.match(/^(\d*\.?\d*)?\*$/);
|
|
1100
|
-
const stars = (match && match[1]) ? parseFloat(match[1]) : 1;
|
|
1101
|
-
resolved = stars * starUnit;
|
|
1102
|
-
}
|
|
1103
|
-
if (resolved < MIN_COLUMN_WIDTH) {
|
|
1104
|
-
throw new PretextPdfError('TABLE_COLUMN_TOO_NARROW', `Table column ${i} resolved to ${resolved.toFixed(1)}pt, minimum is ${MIN_COLUMN_WIDTH.toFixed(1)}pt. ` +
|
|
1105
|
-
`Increase the column width or reduce cellPaddingH/borderWidth.`);
|
|
1106
|
-
}
|
|
1107
|
-
return resolved;
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
/**
|
|
1111
|
-
* Compute which column boundaries have visible vertical lines.
|
|
1112
|
-
* A boundary is "active" (visible) if it's not spanned by any merged cell.
|
|
1113
|
-
* Returns array of boundary indices (0 = between col 0 and 1, 1 = between col 1 and 2, etc.)
|
|
1114
|
-
* where vertical lines should be drawn.
|
|
1115
|
-
*
|
|
1116
|
-
* Example: 3 columns with a cell spanning cols 0-1 → active boundaries are [1] (only between cols 1-2)
|
|
1117
|
-
*/
|
|
1118
|
-
function computeActiveBoundaries(cells, colCount) {
|
|
1119
|
-
// Track which boundaries are "spanned" (internal to a merged cell)
|
|
1120
|
-
const spannedBoundaries = new Set();
|
|
1121
|
-
let colIdx = 0;
|
|
1122
|
-
for (const cell of cells) {
|
|
1123
|
-
const cs = cell.colspan ?? 1;
|
|
1124
|
-
// Boundaries internal to this cell's span are: colIdx to colIdx + cs - 1
|
|
1125
|
-
// The internal boundaries are colIdx, colIdx+1, ..., colIdx+cs-2
|
|
1126
|
-
for (let b = colIdx; b < colIdx + cs - 1; b++) {
|
|
1127
|
-
spannedBoundaries.add(b);
|
|
1128
|
-
}
|
|
1129
|
-
colIdx += cs;
|
|
1130
|
-
}
|
|
1131
|
-
// Active boundaries are all boundaries (0 to colCount-2) that are NOT spanned
|
|
1132
|
-
const activeBoundaries = [];
|
|
1133
|
-
for (let b = 0; b < colCount - 1; b++) {
|
|
1134
|
-
if (!spannedBoundaries.has(b)) {
|
|
1135
|
-
activeBoundaries.push(b);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
return activeBoundaries;
|
|
1139
|
-
}
|
|
1140
|
-
/**
|
|
1141
|
-
* Measure the natural (unwrapped) width of text in pt.
|
|
1142
|
-
* Uses a very large maxWidth so Pretext never wraps — returns the actual line width.
|
|
1143
|
-
*/
|
|
1144
|
-
async function measureNaturalTextWidth(text, fontSize, fontFamily, fontWeight) {
|
|
1145
|
-
if (!text || text.trim() === '')
|
|
1146
|
-
return 0;
|
|
1147
|
-
const { prepareWithSegments, layoutWithLines } = await getPretext();
|
|
1148
|
-
const weightPrefix = fontWeight === 700 ? 'bold ' : '';
|
|
1149
|
-
const fontString = `${weightPrefix}${fontSize}px ${fontFamily}`;
|
|
1150
|
-
// Use a very large width to prevent wrapping; also handle multi-line text (\n)
|
|
1151
|
-
// by taking the max line width across all lines
|
|
1152
|
-
const prepared = prepareWithSegments(text, fontString, { whiteSpace: 'pre-wrap' });
|
|
1153
|
-
const result = layoutWithLines(prepared, 99999, fontSize * LINE_HEIGHT_BODY);
|
|
1154
|
-
const lines = result.lines ?? [];
|
|
1155
|
-
return lines.reduce((max, line) => Math.max(max, line.width), 0);
|
|
1156
|
-
}
|
|
1157
|
-
// ─── Syntax highlighting ──────────────────────────────────────────────────────
|
|
1158
|
-
/** Default GitHub-light-inspired highlight theme colors */
|
|
1159
|
-
const DEFAULT_HIGHLIGHT_THEME = {
|
|
1160
|
-
keyword: '#cf222e',
|
|
1161
|
-
string: '#0a3069',
|
|
1162
|
-
comment: '#6e7781',
|
|
1163
|
-
number: '#0550ae',
|
|
1164
|
-
function: '#8250df',
|
|
1165
|
-
title: '#8250df',
|
|
1166
|
-
built_in: '#0550ae',
|
|
1167
|
-
literal: '#0550ae',
|
|
1168
|
-
type: '#953800',
|
|
1169
|
-
meta: '#cf222e',
|
|
1170
|
-
attr: '#0550ae',
|
|
1171
|
-
name: '#0550ae',
|
|
1172
|
-
params: '#24292f',
|
|
1173
|
-
punctuation: '#24292f',
|
|
1174
|
-
operator: '#24292f',
|
|
1175
|
-
regexp: '#0a3069',
|
|
1176
|
-
variable: '#953800',
|
|
1177
|
-
property: '#0550ae',
|
|
1178
|
-
tag: '#116329',
|
|
1179
|
-
selector: '#116329',
|
|
1180
|
-
subst: '#24292f',
|
|
1181
|
-
'template-tag': '#cf222e',
|
|
1182
|
-
'template-string': '#0a3069',
|
|
1183
|
-
symbol: '#0550ae',
|
|
1184
|
-
addition: '#116329',
|
|
1185
|
-
deletion: '#cf222e',
|
|
1186
|
-
section: '#0550ae',
|
|
1187
|
-
};
|
|
1188
|
-
/** Cached highlight.js module (loaded once, reused across code blocks) */
|
|
1189
|
-
let _hljsCache = null;
|
|
1190
|
-
let _hljsLoadAttempted = false;
|
|
1191
|
-
/**
|
|
1192
|
-
* Tokenize source code into per-line colored spans using highlight.js.
|
|
1193
|
-
* Returns undefined if highlight.js is not installed (renderer falls back to plain text).
|
|
1194
|
-
*/
|
|
1195
|
-
async function tokenizeCodeForHighlighting(text, language, defaultColor, measuredLineCount, customTheme) {
|
|
1196
|
-
if (!_hljsLoadAttempted) {
|
|
1197
|
-
_hljsLoadAttempted = true;
|
|
1198
|
-
try {
|
|
1199
|
-
const mod = await import('highlight.js');
|
|
1200
|
-
_hljsCache = mod.default ?? mod;
|
|
1201
|
-
}
|
|
1202
|
-
catch { /* not installed */ }
|
|
1203
|
-
}
|
|
1204
|
-
if (!_hljsCache)
|
|
1205
|
-
return undefined;
|
|
1206
|
-
const hljs = _hljsCache;
|
|
1207
|
-
const theme = { ...DEFAULT_HIGHLIGHT_THEME };
|
|
1208
|
-
if (customTheme) {
|
|
1209
|
-
for (const [k, v] of Object.entries(customTheme)) {
|
|
1210
|
-
if (v !== undefined)
|
|
1211
|
-
theme[k] = v;
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
let highlighted;
|
|
1215
|
-
try {
|
|
1216
|
-
const result = language === 'auto'
|
|
1217
|
-
? hljs.highlightAuto(text)
|
|
1218
|
-
: hljs.highlight(text, { language });
|
|
1219
|
-
highlighted = result.value;
|
|
1220
|
-
}
|
|
1221
|
-
catch {
|
|
1222
|
-
return undefined;
|
|
1223
|
-
}
|
|
1224
|
-
const tokens = parseHighlightHtml(highlighted, defaultColor, theme);
|
|
1225
|
-
// Safety check: tokenizer splits on \n but the layout engine may wrap long lines.
|
|
1226
|
-
// If line counts don't match, the colors would be applied to the wrong lines.
|
|
1227
|
-
if (tokens.length !== measuredLineCount)
|
|
1228
|
-
return undefined;
|
|
1229
|
-
return tokens;
|
|
1230
|
-
}
|
|
1231
|
-
/**
|
|
1232
|
-
* Parse highlight.js HTML into per-line token arrays.
|
|
1233
|
-
* Handles nested spans (e.g. string interpolation) by tracking a color stack.
|
|
1234
|
-
*/
|
|
1235
|
-
function parseHighlightHtml(html, defaultColor, theme) {
|
|
1236
|
-
const lines = [[]];
|
|
1237
|
-
const colorStack = [defaultColor];
|
|
1238
|
-
let i = 0;
|
|
1239
|
-
while (i < html.length) {
|
|
1240
|
-
if (html[i] === '<') {
|
|
1241
|
-
const closeTag = html.indexOf('>', i);
|
|
1242
|
-
if (closeTag === -1)
|
|
1243
|
-
break;
|
|
1244
|
-
const tag = html.slice(i, closeTag + 1);
|
|
1245
|
-
if (tag.startsWith('<span')) {
|
|
1246
|
-
// Extract class: <span class="hljs-keyword"> or <span class="hljs-template-string">
|
|
1247
|
-
const classMatch = tag.match(/class="hljs-([\w-]+)"/);
|
|
1248
|
-
const cls = classMatch ? classMatch[1] : '';
|
|
1249
|
-
colorStack.push(theme[cls] ?? defaultColor);
|
|
1250
|
-
}
|
|
1251
|
-
else if (tag === '</span>') {
|
|
1252
|
-
if (colorStack.length > 1)
|
|
1253
|
-
colorStack.pop();
|
|
1254
|
-
}
|
|
1255
|
-
i = closeTag + 1;
|
|
1256
|
-
}
|
|
1257
|
-
else if (html[i] === '&') {
|
|
1258
|
-
// HTML entities: named (&), hex (=), decimal (`)
|
|
1259
|
-
const semi = html.indexOf(';', i);
|
|
1260
|
-
if (semi !== -1 && semi - i < 10) {
|
|
1261
|
-
const entity = html.slice(i, semi + 1);
|
|
1262
|
-
let ch;
|
|
1263
|
-
if (entity === '&')
|
|
1264
|
-
ch = '&';
|
|
1265
|
-
else if (entity === '<')
|
|
1266
|
-
ch = '<';
|
|
1267
|
-
else if (entity === '>')
|
|
1268
|
-
ch = '>';
|
|
1269
|
-
else if (entity === '"')
|
|
1270
|
-
ch = '"';
|
|
1271
|
-
else if (entity === ''' || entity === ''')
|
|
1272
|
-
ch = "'";
|
|
1273
|
-
else if (entity.startsWith('&#x'))
|
|
1274
|
-
ch = String.fromCodePoint(parseInt(entity.slice(3, -1), 16));
|
|
1275
|
-
else if (entity.startsWith('&#'))
|
|
1276
|
-
ch = String.fromCodePoint(parseInt(entity.slice(2, -1), 10));
|
|
1277
|
-
else
|
|
1278
|
-
ch = entity; // unknown named entity — keep as-is
|
|
1279
|
-
lines[lines.length - 1].push({ text: ch, color: colorStack[colorStack.length - 1] });
|
|
1280
|
-
i = semi + 1;
|
|
1281
|
-
}
|
|
1282
|
-
else {
|
|
1283
|
-
lines[lines.length - 1].push({ text: '&', color: colorStack[colorStack.length - 1] });
|
|
1284
|
-
i++;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
else if (html[i] === '\n') {
|
|
1288
|
-
lines.push([]);
|
|
1289
|
-
i++;
|
|
1290
|
-
}
|
|
1291
|
-
else {
|
|
1292
|
-
// Regular text — accumulate consecutive chars with same color
|
|
1293
|
-
const color = colorStack[colorStack.length - 1];
|
|
1294
|
-
let end = i + 1;
|
|
1295
|
-
while (end < html.length && html[end] !== '<' && html[end] !== '&' && html[end] !== '\n')
|
|
1296
|
-
end++;
|
|
1297
|
-
lines[lines.length - 1].push({ text: html.slice(i, end), color });
|
|
1298
|
-
i = end;
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
// Merge adjacent tokens with the same color on each line (fewer drawText calls)
|
|
1302
|
-
for (const line of lines) {
|
|
1303
|
-
for (let j = line.length - 1; j > 0; j--) {
|
|
1304
|
-
if (line[j].color === line[j - 1].color) {
|
|
1305
|
-
line[j - 1].text += line[j].text;
|
|
1306
|
-
line.splice(j, 1);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
return lines;
|
|
1311
|
-
}
|
|
1312
|
-
// ─── Text measurement (shared by all text-bearing elements) ──────────────────
|
|
1313
|
-
/**
|
|
1314
|
-
* Measure text with automatic word hyphenation (Liang's algorithm via hypher).
|
|
1315
|
-
* Splits on \n to preserve paragraph breaks; tokenizes words; greedily packs with hyphenation fallback.
|
|
1316
|
-
*/
|
|
1317
|
-
//# sourceMappingURL=measure-blocks.js.map
|