pretext-pdf 0.5.3 → 0.8.1
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 +115 -0
- package/README.md +366 -276
- package/dist/assets.d.ts +5 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +248 -43
- package/dist/assets.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +88 -16
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +29 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -2
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +222 -0
- package/dist/markdown.js.map +1 -0
- package/dist/measure-blocks.d.ts.map +1 -1
- package/dist/measure-blocks.js +347 -62
- package/dist/measure-blocks.js.map +1 -1
- package/dist/measure-text.d.ts.map +1 -1
- package/dist/measure-text.js +1 -8
- package/dist/measure-text.js.map +1 -1
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +13 -21
- package/dist/measure.js.map +1 -1
- package/dist/render-blocks.d.ts +4 -1
- package/dist/render-blocks.d.ts.map +1 -1
- package/dist/render-blocks.js +227 -105
- package/dist/render-blocks.js.map +1 -1
- package/dist/render-extras.d.ts.map +1 -1
- package/dist/render-extras.js +72 -71
- package/dist/render-extras.js.map +1 -1
- package/dist/render-utils.d.ts +9 -2
- package/dist/render-utils.d.ts.map +1 -1
- package/dist/render-utils.js +24 -13
- package/dist/render-utils.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +27 -3
- package/dist/render.js.map +1 -1
- package/dist/rich-text.d.ts +0 -4
- package/dist/rich-text.d.ts.map +1 -1
- package/dist/rich-text.js +15 -9
- package/dist/rich-text.js.map +1 -1
- package/dist/templates.d.ts +79 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +201 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +139 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +241 -28
- package/dist/validate.js.map +1 -1
- package/package.json +57 -12
package/dist/measure-blocks.js
CHANGED
|
@@ -6,6 +6,7 @@ import { PretextPdfError } from './errors.js';
|
|
|
6
6
|
import { measureRichText } from './rich-text.js';
|
|
7
7
|
import { buildFontKey } from './measure.js';
|
|
8
8
|
import { measureText, getPretext, detectAndReorderRTL } from './measure-text.js';
|
|
9
|
+
import { LINE_HEIGHT_BODY, LINE_HEIGHT_COMPACT } from './render-utils.js';
|
|
9
10
|
/** Heading level size multipliers and defaults */
|
|
10
11
|
const HEADING_DEFAULTS = {
|
|
11
12
|
1: { sizeMultiplier: 2.0, fontWeight: 700, spaceAfter: 16, spaceBefore: 28 },
|
|
@@ -68,7 +69,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
68
69
|
case 'form-field': {
|
|
69
70
|
const el = element;
|
|
70
71
|
const fs = el.fontSize ?? baseFontSize;
|
|
71
|
-
const labelHeight = el.label ? fs *
|
|
72
|
+
const labelHeight = el.label ? fs * LINE_HEIGHT_BODY + 4 : 0;
|
|
72
73
|
let fieldHeight = el.height;
|
|
73
74
|
if (!fieldHeight) {
|
|
74
75
|
if (el.fieldType === 'text' && el.multiline)
|
|
@@ -97,7 +98,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
97
98
|
// smallCaps renders at 80% of fontSize — measure at the same size to avoid
|
|
98
99
|
// overestimating block height and wasting vertical space
|
|
99
100
|
const effectiveFontSize = element.smallCaps === true ? fontSize * 0.8 : fontSize;
|
|
100
|
-
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize *
|
|
101
|
+
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize * LINE_HEIGHT_BODY);
|
|
101
102
|
const fontFamily = element.fontFamily ?? doc.defaultParagraphStyle?.fontFamily ?? baseFont;
|
|
102
103
|
const fontWeight = element.fontWeight ?? doc.defaultParagraphStyle?.fontWeight ?? 400;
|
|
103
104
|
const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
@@ -108,8 +109,8 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
108
109
|
// Multi-column layout
|
|
109
110
|
let computedColumnWidth = contentWidth;
|
|
110
111
|
if (columns > 1) {
|
|
111
|
-
if (columns >
|
|
112
|
-
throw new PretextPdfError('VALIDATION_ERROR', `columns must be 1–
|
|
112
|
+
if (columns > 6) {
|
|
113
|
+
throw new PretextPdfError('VALIDATION_ERROR', `columns must be 1–6, got ${columns}`);
|
|
113
114
|
}
|
|
114
115
|
computedColumnWidth = (contentWidth - (columns - 1) * columnGap) / columns;
|
|
115
116
|
if (computedColumnWidth < 50) {
|
|
@@ -177,7 +178,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
177
178
|
const fontSize = element.fontSize ?? (baseHeadingFontSize * defaults.sizeMultiplier);
|
|
178
179
|
// smallCaps renders at 80% — measure at effective size
|
|
179
180
|
const effectiveFontSize = element.smallCaps === true ? fontSize * 0.8 : fontSize;
|
|
180
|
-
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize *
|
|
181
|
+
const lineHeight = element.lineHeight ?? doc.defaultParagraphStyle?.lineHeight ?? doc.defaultLineHeight ?? (effectiveFontSize * LINE_HEIGHT_COMPACT);
|
|
181
182
|
const fontFamily = element.fontFamily ?? doc.defaultParagraphStyle?.fontFamily ?? baseFont;
|
|
182
183
|
const fontWeight = element.fontWeight ?? doc.defaultParagraphStyle?.fontWeight ?? defaults.fontWeight;
|
|
183
184
|
const fontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
@@ -243,7 +244,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
243
244
|
const fullText = element.spans.map(s => s.text).join('');
|
|
244
245
|
const { isRTL } = await detectAndReorderRTL(fullText, element.dir);
|
|
245
246
|
const fontSize = element.fontSize ?? baseFontSize;
|
|
246
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize *
|
|
247
|
+
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
247
248
|
// 'justify' uses left alignment for measurement (justify is rendering-only)
|
|
248
249
|
const alignRaw = element.align ?? 'left';
|
|
249
250
|
const align = alignRaw === 'justify' ? 'left' : alignRaw;
|
|
@@ -307,7 +308,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
307
308
|
}
|
|
308
309
|
case 'code': {
|
|
309
310
|
const fontSize = element.fontSize ?? Math.max(baseFontSize - 2, 8);
|
|
310
|
-
const lineHeight = element.lineHeight ?? (fontSize *
|
|
311
|
+
const lineHeight = element.lineHeight ?? (fontSize * LINE_HEIGHT_COMPACT);
|
|
311
312
|
const padding = element.padding ?? 8;
|
|
312
313
|
// Text area is narrower by padding on both sides
|
|
313
314
|
const textWidth = contentWidth - 2 * padding;
|
|
@@ -316,6 +317,11 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
316
317
|
const lines = await measureText(element.text, fontSize, element.fontFamily, 400, Math.max(textWidth, 1), lineHeight);
|
|
317
318
|
// height = lines * lineHeight + padding top + padding bottom
|
|
318
319
|
const height = (lines.length || 1) * lineHeight + 2 * padding;
|
|
320
|
+
// Syntax highlighting: tokenize if language is set
|
|
321
|
+
let codeHighlightTokens;
|
|
322
|
+
if (element.language) {
|
|
323
|
+
codeHighlightTokens = await tokenizeCodeForHighlighting(element.text, element.language, element.color ?? '#24292f', lines.length, element.highlightTheme);
|
|
324
|
+
}
|
|
319
325
|
return {
|
|
320
326
|
element,
|
|
321
327
|
height,
|
|
@@ -326,6 +332,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
326
332
|
spaceAfter: element.spaceAfter ?? 12,
|
|
327
333
|
spaceBefore: element.spaceBefore ?? 12,
|
|
328
334
|
codePadding: padding,
|
|
335
|
+
...(codeHighlightTokens ? { codeHighlightTokens } : {}),
|
|
329
336
|
isRTL: false, // NEW (Phase 7F): Code blocks always LTR
|
|
330
337
|
};
|
|
331
338
|
}
|
|
@@ -333,7 +340,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
333
340
|
// NEW (Phase 7F): Detect and reorder RTL text
|
|
334
341
|
const { visual: visualText, isRTL, logical: logicalText } = await detectAndReorderRTL(element.text, element.dir);
|
|
335
342
|
const fontSize = element.fontSize ?? baseFontSize;
|
|
336
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize *
|
|
343
|
+
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
337
344
|
const fontFamily = element.fontFamily ?? baseFont;
|
|
338
345
|
const fontWeight = element.fontWeight ?? 400;
|
|
339
346
|
const fontStyle = element.fontStyle ?? 'normal';
|
|
@@ -366,7 +373,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
366
373
|
case 'callout': {
|
|
367
374
|
const el = element;
|
|
368
375
|
const fs = el.fontSize ?? baseFontSize;
|
|
369
|
-
const lh = el.lineHeight ?? (fs *
|
|
376
|
+
const lh = el.lineHeight ?? (fs * LINE_HEIGHT_BODY);
|
|
370
377
|
const ph = el.paddingH ?? el.padding ?? 16;
|
|
371
378
|
const pv = el.paddingV ?? el.padding ?? 10;
|
|
372
379
|
const family = el.fontFamily ?? baseFont;
|
|
@@ -378,7 +385,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
378
385
|
// Measure title height (one line assumed, bold)
|
|
379
386
|
let titleHeight = 0;
|
|
380
387
|
if (el.title) {
|
|
381
|
-
titleHeight = fs *
|
|
388
|
+
titleHeight = fs * LINE_HEIGHT_COMPACT + 4; // compact line height + 4pt separator
|
|
382
389
|
}
|
|
383
390
|
// Measure content text
|
|
384
391
|
const innerWidth = contentWidth - ph * 2;
|
|
@@ -421,7 +428,7 @@ export async function measureBlock(element, contentWidth, doc, hyphenatorOpts) {
|
|
|
421
428
|
const fn = element;
|
|
422
429
|
const baseFontSize = doc.defaultFontSize ?? 12;
|
|
423
430
|
const fontSize = fn.fontSize ?? Math.max(8, baseFontSize - 2);
|
|
424
|
-
const lineHeight = fontSize *
|
|
431
|
+
const lineHeight = fontSize * LINE_HEIGHT_BODY;
|
|
425
432
|
const fontFamily = fn.fontFamily ?? doc.defaultFont ?? 'Inter';
|
|
426
433
|
const fontKey = buildFontKey(fontFamily, 400, 'normal');
|
|
427
434
|
// Measure the def text with a 20pt left indent (for the number prefix space)
|
|
@@ -534,16 +541,25 @@ export async function measureFloatImageBlock(element, imageKey, imageMap, conten
|
|
|
534
541
|
const imageBlock = await measureImageWithKey(syntheticEl, imageKey, imageMap, floatWidth, pageContentHeight);
|
|
535
542
|
const imageRenderWidth = imageBlock.imageData.renderWidth;
|
|
536
543
|
const imageRenderHeight = imageBlock.imageData.renderHeight;
|
|
537
|
-
// Measure the float text
|
|
544
|
+
// Measure the float text (plain or rich)
|
|
538
545
|
const fontSize = element.floatFontSize ?? doc.defaultFontSize ?? 12;
|
|
539
|
-
const lineHeight = fontSize *
|
|
546
|
+
const lineHeight = fontSize * LINE_HEIGHT_BODY;
|
|
540
547
|
const fontFamily = element.floatFontFamily ?? doc.defaultFont ?? 'Inter';
|
|
541
548
|
const fontKey = buildFontKey(fontFamily, 400, 'normal');
|
|
542
|
-
|
|
549
|
+
let textLines = [];
|
|
550
|
+
let richFloatLines;
|
|
551
|
+
if (element.floatSpans) {
|
|
552
|
+
richFloatLines = await measureRichText(element.floatSpans, fontSize, lineHeight, textColWidth, 'left', doc);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
textLines = await measureText(element.floatText, fontSize, fontFamily, 400, textColWidth, lineHeight, undefined);
|
|
556
|
+
}
|
|
543
557
|
// Column X positions
|
|
544
558
|
const imageColX = element.float === 'left' ? 0 : textColWidth + floatGap;
|
|
545
559
|
const textColX = element.float === 'left' ? floatWidth + floatGap : 0;
|
|
546
|
-
const textHeight =
|
|
560
|
+
const textHeight = richFloatLines
|
|
561
|
+
? richFloatLines.reduce((sum, l) => sum + l.lineHeight, 0)
|
|
562
|
+
: textLines.length * lineHeight;
|
|
547
563
|
const compositeHeight = Math.max(imageRenderHeight, textHeight);
|
|
548
564
|
return {
|
|
549
565
|
element,
|
|
@@ -562,6 +578,7 @@ export async function measureFloatImageBlock(element, imageKey, imageMap, conten
|
|
|
562
578
|
textColX,
|
|
563
579
|
textColWidth,
|
|
564
580
|
textLines,
|
|
581
|
+
...(richFloatLines ? { richFloatLines } : {}),
|
|
565
582
|
textFontKey: fontKey,
|
|
566
583
|
textFontSize: fontSize,
|
|
567
584
|
textLineHeight: lineHeight,
|
|
@@ -600,7 +617,7 @@ export async function measureFloatGroup(element, imageKey, imageMap, contentWidt
|
|
|
600
617
|
const blocks = Array.isArray(measuredEl) ? measuredEl : [measuredEl];
|
|
601
618
|
for (const block of blocks) {
|
|
602
619
|
const fontSize = block.fontSize || baseFontSize;
|
|
603
|
-
const lineHeight = block.lineHeight || (fontSize *
|
|
620
|
+
const lineHeight = block.lineHeight || (fontSize * LINE_HEIGHT_BODY);
|
|
604
621
|
// Extract text from lines or rich-lines
|
|
605
622
|
let lines = [];
|
|
606
623
|
if (block.richLines && block.richLines.length > 0) {
|
|
@@ -663,12 +680,12 @@ export async function measureFloatGroup(element, imageKey, imageMap, contentWidt
|
|
|
663
680
|
async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
|
|
664
681
|
const baseFontFamily = doc.defaultFont ?? 'Inter';
|
|
665
682
|
const fontSize = element.fontSize ?? baseFontSize;
|
|
666
|
-
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize *
|
|
683
|
+
const lineHeight = element.lineHeight ?? doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
667
684
|
const indent = element.indent ?? 20;
|
|
668
685
|
const itemSpaceAfter = element.itemSpaceAfter ?? 4;
|
|
669
686
|
const fontKey = buildFontKey(baseFontFamily, 400, 'normal');
|
|
670
687
|
const blocks = [];
|
|
671
|
-
// Flatten items and nested items
|
|
688
|
+
// Flatten items and nested items (up to 2 levels deep)
|
|
672
689
|
const nestedStyle = element.nestedNumberingStyle ?? 'continue';
|
|
673
690
|
let orderedIndex = 1;
|
|
674
691
|
const allItems = [];
|
|
@@ -679,8 +696,8 @@ async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorO
|
|
|
679
696
|
? `${orderedIndex}.`
|
|
680
697
|
: (element.marker ?? '•');
|
|
681
698
|
orderedIndex++;
|
|
682
|
-
allItems.push({ text: item.text, marker,
|
|
683
|
-
// Nested items (1
|
|
699
|
+
allItems.push({ text: item.text, marker, depth: 0, isFirstInList: isFirst, fontWeight: item.fontWeight ?? 400 });
|
|
700
|
+
// Nested items (depth 1)
|
|
684
701
|
if (item.items && item.items.length > 0) {
|
|
685
702
|
// 'restart': nested ordered items count from 1, parent counter unaffected
|
|
686
703
|
// 'continue': nested items share the parent counter (existing behavior)
|
|
@@ -689,11 +706,25 @@ async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorO
|
|
|
689
706
|
const nested = item.items[ni];
|
|
690
707
|
const nestedMarker = element.style === 'ordered'
|
|
691
708
|
? `${nestedIndex}.`
|
|
692
|
-
: '◦'; // hollow bullet for
|
|
709
|
+
: '◦'; // hollow bullet for depth-1 unordered
|
|
693
710
|
nestedIndex++;
|
|
694
711
|
if (nestedStyle === 'continue')
|
|
695
712
|
orderedIndex++;
|
|
696
|
-
allItems.push({ text: nested.text, marker: nestedMarker,
|
|
713
|
+
allItems.push({ text: nested.text, marker: nestedMarker, depth: 1, isFirstInList: false, fontWeight: nested.fontWeight ?? 400 });
|
|
714
|
+
// Nested items (depth 2)
|
|
715
|
+
if (nested.items && nested.items.length > 0) {
|
|
716
|
+
let deepIndex = nestedStyle === 'restart' ? 1 : nestedIndex;
|
|
717
|
+
for (let di = 0; di < nested.items.length; di++) {
|
|
718
|
+
const deep = nested.items[di];
|
|
719
|
+
const deepMarker = element.style === 'ordered'
|
|
720
|
+
? `${deepIndex}.`
|
|
721
|
+
: '▪'; // small filled square for depth-2 unordered
|
|
722
|
+
deepIndex++;
|
|
723
|
+
if (nestedStyle === 'continue')
|
|
724
|
+
orderedIndex++;
|
|
725
|
+
allItems.push({ text: deep.text, marker: deepMarker, depth: 2, isFirstInList: false, fontWeight: deep.fontWeight ?? 400 });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
697
728
|
}
|
|
698
729
|
}
|
|
699
730
|
}
|
|
@@ -717,8 +748,8 @@ async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorO
|
|
|
717
748
|
for (let i = 0; i < allItems.length; i++) {
|
|
718
749
|
const item = allItems[i];
|
|
719
750
|
const isLast = i === allItems.length - 1;
|
|
720
|
-
const nestedIndent =
|
|
721
|
-
const nestedTextWidth =
|
|
751
|
+
const nestedIndent = indent + item.depth * markerWidth;
|
|
752
|
+
const nestedTextWidth = textWidth - item.depth * markerWidth;
|
|
722
753
|
const lines = await measureText(item.text, fontSize, baseFontFamily, item.fontWeight, nestedTextWidth, lineHeight, hyphenatorOpts);
|
|
723
754
|
const listItemData = {
|
|
724
755
|
marker: item.marker,
|
|
@@ -745,25 +776,49 @@ async function measureList(element, contentWidth, doc, baseFontSize, hyphenatorO
|
|
|
745
776
|
}
|
|
746
777
|
return blocks;
|
|
747
778
|
}
|
|
748
|
-
|
|
779
|
+
/** Build a map from "rowIdx,colIdx" → span origin info for all positions occupied by a rowspan cell from an earlier row. */
|
|
780
|
+
function buildSpanGrid(rows) {
|
|
781
|
+
const grid = new Map();
|
|
782
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
783
|
+
let ci = 0;
|
|
784
|
+
for (const cell of rows[ri].cells) {
|
|
785
|
+
while (grid.has(`${ri},${ci}`))
|
|
786
|
+
ci++;
|
|
787
|
+
const cs = cell.colspan ?? 1;
|
|
788
|
+
const rs = cell.rowspan ?? 1;
|
|
789
|
+
for (let r2 = ri + 1; r2 < ri + rs; r2++) {
|
|
790
|
+
for (let c2 = ci; c2 < ci + cs; c2++) {
|
|
791
|
+
grid.set(`${r2},${c2}`, { originRowIdx: ri, originColStart: ci, colspan: cs, rowspan: rs });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
ci += cs;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return grid;
|
|
798
|
+
}
|
|
749
799
|
async function measureTable(element, contentWidth, doc, baseFontSize, hyphenatorOpts) {
|
|
750
800
|
const baseFontFamily = doc.defaultFont ?? 'Inter';
|
|
751
801
|
const fontSize = element.fontSize ?? baseFontSize;
|
|
752
|
-
const lineHeight = doc.defaultLineHeight ?? (fontSize *
|
|
802
|
+
const lineHeight = doc.defaultLineHeight ?? (fontSize * LINE_HEIGHT_BODY);
|
|
753
803
|
const cellPaddingH = element.cellPaddingH ?? 8;
|
|
754
804
|
const cellPaddingV = element.cellPaddingV ?? 6;
|
|
755
805
|
const borderWidth = element.borderWidth ?? 0.5;
|
|
756
806
|
const borderColor = element.borderColor ?? '#cccccc';
|
|
757
807
|
const headerBgColor = element.headerBgColor ?? '#f5f5f5';
|
|
808
|
+
// Build span occupancy grid (needed for correct colStart tracking in all passes)
|
|
809
|
+
const spanGrid = buildSpanGrid(element.rows);
|
|
758
810
|
// Pre-pass: measure natural widths for 'auto' columns — run all in parallel
|
|
759
811
|
const hasAutoColumns = element.columns.some(c => c.width === 'auto');
|
|
760
812
|
let naturalWidths;
|
|
761
813
|
if (hasAutoColumns) {
|
|
762
814
|
naturalWidths = new Array(element.columns.length).fill(0);
|
|
763
815
|
const jobs = [];
|
|
764
|
-
for (
|
|
816
|
+
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
817
|
+
const row = element.rows[rowIdx];
|
|
765
818
|
let colIdx = 0;
|
|
766
819
|
for (const cell of row.cells) {
|
|
820
|
+
while (spanGrid.has(`${rowIdx},${colIdx}`))
|
|
821
|
+
colIdx++;
|
|
767
822
|
const cs = cell.colspan ?? 1;
|
|
768
823
|
if (element.columns[colIdx]?.width === 'auto') {
|
|
769
824
|
jobs.push({
|
|
@@ -798,10 +853,14 @@ async function measureTable(element, contentWidth, doc, baseFontSize, hyphenator
|
|
|
798
853
|
? element.headerRows
|
|
799
854
|
: element.rows.filter(r => r.isHeader).length;
|
|
800
855
|
const allCellMeta = [];
|
|
801
|
-
for (
|
|
856
|
+
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
857
|
+
const row = element.rows[rowIdx];
|
|
802
858
|
let colStart = 0;
|
|
803
859
|
for (const cell of row.cells) {
|
|
860
|
+
while (spanGrid.has(`${rowIdx},${colStart}`))
|
|
861
|
+
colStart++;
|
|
804
862
|
const cs = cell.colspan ?? 1;
|
|
863
|
+
const rs = cell.rowspan ?? 1;
|
|
805
864
|
const col = element.columns[colStart];
|
|
806
865
|
let mergedWidth = 0;
|
|
807
866
|
for (let si = colStart; si < colStart + cs && si < columnWidths.length; si++) {
|
|
@@ -812,11 +871,11 @@ async function measureTable(element, contentWidth, doc, baseFontSize, hyphenator
|
|
|
812
871
|
const fontWeight = (cell.fontWeight ?? (row.isHeader ? 700 : 400));
|
|
813
872
|
const fontFamily = cell.fontFamily ?? baseFontFamily;
|
|
814
873
|
const cellFontSize = cell.fontSize ?? fontSize;
|
|
815
|
-
const cellLineHeight = doc.defaultLineHeight ?? (cellFontSize *
|
|
874
|
+
const cellLineHeight = doc.defaultLineHeight ?? (cellFontSize * LINE_HEIGHT_BODY);
|
|
816
875
|
const cellFontKey = buildFontKey(fontFamily, fontWeight, 'normal');
|
|
817
876
|
const textWidth = mergedWidth - 2 * cellPaddingH - borderWidth;
|
|
818
|
-
const cellDir = (cell.dir ?? 'auto');
|
|
819
|
-
allCellMeta.push({ cell, row, cs, col, mergedWidth, fontWeight, fontFamily, cellFontSize, cellLineHeight, cellFontKey, textWidth, cellDir });
|
|
877
|
+
const cellDir = (cell.dir ?? element.dir ?? 'auto');
|
|
878
|
+
allCellMeta.push({ cell, row, rowIdx, colStart, cs, rs, col, mergedWidth, fontWeight, fontFamily, cellFontSize, cellLineHeight, cellFontKey, textWidth, cellDir });
|
|
820
879
|
colStart += cs;
|
|
821
880
|
}
|
|
822
881
|
}
|
|
@@ -826,39 +885,110 @@ async function measureTable(element, contentWidth, doc, baseFontSize, hyphenator
|
|
|
826
885
|
const lines = await measureText(cellVisualText, m.cellFontSize, m.fontFamily, m.fontWeight, Math.max(m.textWidth, 1), m.cellLineHeight, hyphenatorOpts);
|
|
827
886
|
return { cellVisualText, cellIsRTL, lines };
|
|
828
887
|
}));
|
|
829
|
-
|
|
888
|
+
const cellByKey = new Map();
|
|
889
|
+
for (let i = 0; i < allCellMeta.length; i++) {
|
|
890
|
+
const m = allCellMeta[i];
|
|
891
|
+
cellByKey.set(`${m.rowIdx},${m.colStart}`, { meta: m, result: cellResults[i] });
|
|
892
|
+
}
|
|
893
|
+
// Sub-pass 3a: Compute raw row heights from non-spanning cells only
|
|
894
|
+
const rowHeights = new Array(element.rows.length).fill(0);
|
|
895
|
+
for (const { meta: m, result } of cellByKey.values()) {
|
|
896
|
+
if (m.rs === 1) {
|
|
897
|
+
const cellContentHeight = Math.max(result.lines.length, 1) * m.cellLineHeight;
|
|
898
|
+
rowHeights[m.rowIdx] = Math.max(rowHeights[m.rowIdx], cellContentHeight);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (let ri = 0; ri < rowHeights.length; ri++) {
|
|
902
|
+
rowHeights[ri] = (rowHeights[ri] ?? 0) + 2 * cellPaddingV;
|
|
903
|
+
}
|
|
904
|
+
// Sub-pass 3b: Expand last spanned row if spanning cell needs more space
|
|
905
|
+
for (const { meta: m, result } of cellByKey.values()) {
|
|
906
|
+
if (m.rs > 1) {
|
|
907
|
+
const cellContentHeight = Math.max(result.lines.length, 1) * m.cellLineHeight + 2 * cellPaddingV;
|
|
908
|
+
let spanHeight = 0;
|
|
909
|
+
for (let r2 = m.rowIdx; r2 < m.rowIdx + m.rs && r2 < rowHeights.length; r2++) {
|
|
910
|
+
spanHeight += rowHeights[r2];
|
|
911
|
+
}
|
|
912
|
+
if (cellContentHeight > spanHeight) {
|
|
913
|
+
const lastRowIdx = Math.min(m.rowIdx + m.rs - 1, rowHeights.length - 1);
|
|
914
|
+
rowHeights[lastRowIdx] += cellContentHeight - spanHeight;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Sub-pass 3c: Build measuredRows, inserting placeholder cells for rowspan continuations
|
|
830
919
|
const measuredRows = [];
|
|
831
|
-
|
|
832
|
-
for (
|
|
920
|
+
const numColumns = element.columns.length;
|
|
921
|
+
for (let rowIdx = 0; rowIdx < element.rows.length; rowIdx++) {
|
|
922
|
+
const row = element.rows[rowIdx];
|
|
923
|
+
const rowHeight = rowHeights[rowIdx];
|
|
833
924
|
const measuredCells = [];
|
|
834
|
-
let
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
925
|
+
let hasRowspan = false;
|
|
926
|
+
let colCursor = 0;
|
|
927
|
+
while (colCursor < numColumns) {
|
|
928
|
+
const spanEntry = spanGrid.get(`${rowIdx},${colCursor}`);
|
|
929
|
+
if (spanEntry) {
|
|
930
|
+
// Only insert ONE placeholder per span group (at the leftmost column of the span)
|
|
931
|
+
if (colCursor === spanEntry.originColStart) {
|
|
932
|
+
const originCell = cellByKey.get(`${spanEntry.originRowIdx},${spanEntry.originColStart}`);
|
|
933
|
+
const pw = originCell?.meta.mergedWidth ?? (columnWidths[colCursor] ?? 0);
|
|
934
|
+
measuredCells.push({
|
|
935
|
+
lines: [], fontSize: 0, lineHeight: 0, fontKey: '', fontFamily: '',
|
|
936
|
+
align: 'left', color: '#000000',
|
|
937
|
+
colspan: spanEntry.colspan, mergedWidth: pw,
|
|
938
|
+
isSpanPlaceholder: true,
|
|
939
|
+
});
|
|
940
|
+
colCursor += spanEntry.colspan;
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
// Mid-span column not at origin — advance past the full span group
|
|
944
|
+
colCursor += spanEntry.colspan;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
const cellAtCol = cellByKey.get(`${rowIdx},${colCursor}`);
|
|
949
|
+
if (!cellAtCol) {
|
|
950
|
+
colCursor++;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const { meta: m, result: { cellIsRTL, lines } } = cellAtCol;
|
|
954
|
+
let spanHeight;
|
|
955
|
+
if (m.rs > 1) {
|
|
956
|
+
spanHeight = 0;
|
|
957
|
+
for (let r2 = rowIdx; r2 < rowIdx + m.rs && r2 < rowHeights.length; r2++) {
|
|
958
|
+
spanHeight += rowHeights[r2];
|
|
959
|
+
}
|
|
960
|
+
hasRowspan = true;
|
|
961
|
+
}
|
|
962
|
+
const align = m.cell.align ?? m.col.align ?? (cellIsRTL ? 'right' : 'left');
|
|
963
|
+
const measuredCell = {
|
|
964
|
+
lines,
|
|
965
|
+
fontSize: m.cellFontSize,
|
|
966
|
+
lineHeight: m.cellLineHeight,
|
|
967
|
+
fontKey: m.cellFontKey,
|
|
968
|
+
fontFamily: m.fontFamily,
|
|
969
|
+
align,
|
|
970
|
+
color: m.cell.color ?? '#000000',
|
|
971
|
+
colspan: m.cs,
|
|
972
|
+
mergedWidth: m.mergedWidth,
|
|
973
|
+
isRTL: cellIsRTL,
|
|
974
|
+
...(m.cell.tabularNumbers !== undefined && { tabularNumbers: m.cell.tabularNumbers }),
|
|
975
|
+
...(m.rs > 1 ? { rowspan: m.rs } : {}),
|
|
976
|
+
...(spanHeight !== undefined ? { spanHeight } : {}),
|
|
977
|
+
};
|
|
978
|
+
if (m.cell.bgColor !== undefined)
|
|
979
|
+
measuredCell.bgColor = m.cell.bgColor;
|
|
980
|
+
measuredCells.push(measuredCell);
|
|
981
|
+
colCursor += m.cs;
|
|
982
|
+
}
|
|
858
983
|
}
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
984
|
+
const activeBoundaries = computeActiveBoundaries(measuredCells, numColumns);
|
|
985
|
+
measuredRows.push({
|
|
986
|
+
cells: measuredCells,
|
|
987
|
+
height: rowHeight,
|
|
988
|
+
isHeader: row.isHeader ?? false,
|
|
989
|
+
activeBoundaries,
|
|
990
|
+
...(hasRowspan ? { hasRowspan: true } : {}),
|
|
991
|
+
});
|
|
862
992
|
}
|
|
863
993
|
// Header rows are the first N rows
|
|
864
994
|
const headerRows = measuredRows.slice(0, headerRowCount);
|
|
@@ -1007,10 +1137,165 @@ async function measureNaturalTextWidth(text, fontSize, fontFamily, fontWeight) {
|
|
|
1007
1137
|
// Use a very large width to prevent wrapping; also handle multi-line text (\n)
|
|
1008
1138
|
// by taking the max line width across all lines
|
|
1009
1139
|
const prepared = prepareWithSegments(text, fontString, { whiteSpace: 'pre-wrap' });
|
|
1010
|
-
const result = layoutWithLines(prepared, 99999, fontSize *
|
|
1140
|
+
const result = layoutWithLines(prepared, 99999, fontSize * LINE_HEIGHT_BODY);
|
|
1011
1141
|
const lines = result.lines ?? [];
|
|
1012
1142
|
return lines.reduce((max, line) => Math.max(max, line.width), 0);
|
|
1013
1143
|
}
|
|
1144
|
+
// ─── Syntax highlighting ──────────────────────────────────────────────────────
|
|
1145
|
+
/** Default GitHub-light-inspired highlight theme colors */
|
|
1146
|
+
const DEFAULT_HIGHLIGHT_THEME = {
|
|
1147
|
+
keyword: '#cf222e',
|
|
1148
|
+
string: '#0a3069',
|
|
1149
|
+
comment: '#6e7781',
|
|
1150
|
+
number: '#0550ae',
|
|
1151
|
+
function: '#8250df',
|
|
1152
|
+
title: '#8250df',
|
|
1153
|
+
built_in: '#0550ae',
|
|
1154
|
+
literal: '#0550ae',
|
|
1155
|
+
type: '#953800',
|
|
1156
|
+
meta: '#cf222e',
|
|
1157
|
+
attr: '#0550ae',
|
|
1158
|
+
name: '#0550ae',
|
|
1159
|
+
params: '#24292f',
|
|
1160
|
+
punctuation: '#24292f',
|
|
1161
|
+
operator: '#24292f',
|
|
1162
|
+
regexp: '#0a3069',
|
|
1163
|
+
variable: '#953800',
|
|
1164
|
+
property: '#0550ae',
|
|
1165
|
+
tag: '#116329',
|
|
1166
|
+
selector: '#116329',
|
|
1167
|
+
subst: '#24292f',
|
|
1168
|
+
'template-tag': '#cf222e',
|
|
1169
|
+
'template-string': '#0a3069',
|
|
1170
|
+
symbol: '#0550ae',
|
|
1171
|
+
addition: '#116329',
|
|
1172
|
+
deletion: '#cf222e',
|
|
1173
|
+
section: '#0550ae',
|
|
1174
|
+
};
|
|
1175
|
+
/** Cached highlight.js module (loaded once, reused across code blocks) */
|
|
1176
|
+
let _hljsCache = null;
|
|
1177
|
+
let _hljsLoadAttempted = false;
|
|
1178
|
+
/**
|
|
1179
|
+
* Tokenize source code into per-line colored spans using highlight.js.
|
|
1180
|
+
* Returns undefined if highlight.js is not installed (renderer falls back to plain text).
|
|
1181
|
+
*/
|
|
1182
|
+
async function tokenizeCodeForHighlighting(text, language, defaultColor, measuredLineCount, customTheme) {
|
|
1183
|
+
if (!_hljsLoadAttempted) {
|
|
1184
|
+
_hljsLoadAttempted = true;
|
|
1185
|
+
try {
|
|
1186
|
+
const mod = await import('highlight.js');
|
|
1187
|
+
_hljsCache = mod.default ?? mod;
|
|
1188
|
+
}
|
|
1189
|
+
catch { /* not installed */ }
|
|
1190
|
+
}
|
|
1191
|
+
if (!_hljsCache)
|
|
1192
|
+
return undefined;
|
|
1193
|
+
const hljs = _hljsCache;
|
|
1194
|
+
const theme = { ...DEFAULT_HIGHLIGHT_THEME };
|
|
1195
|
+
if (customTheme) {
|
|
1196
|
+
for (const [k, v] of Object.entries(customTheme)) {
|
|
1197
|
+
if (v !== undefined)
|
|
1198
|
+
theme[k] = v;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
let highlighted;
|
|
1202
|
+
try {
|
|
1203
|
+
const result = language === 'auto'
|
|
1204
|
+
? hljs.highlightAuto(text)
|
|
1205
|
+
: hljs.highlight(text, { language });
|
|
1206
|
+
highlighted = result.value;
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
const tokens = parseHighlightHtml(highlighted, defaultColor, theme);
|
|
1212
|
+
// Safety check: tokenizer splits on \n but the layout engine may wrap long lines.
|
|
1213
|
+
// If line counts don't match, the colors would be applied to the wrong lines.
|
|
1214
|
+
if (tokens.length !== measuredLineCount)
|
|
1215
|
+
return undefined;
|
|
1216
|
+
return tokens;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Parse highlight.js HTML into per-line token arrays.
|
|
1220
|
+
* Handles nested spans (e.g. string interpolation) by tracking a color stack.
|
|
1221
|
+
*/
|
|
1222
|
+
function parseHighlightHtml(html, defaultColor, theme) {
|
|
1223
|
+
const lines = [[]];
|
|
1224
|
+
const colorStack = [defaultColor];
|
|
1225
|
+
let i = 0;
|
|
1226
|
+
while (i < html.length) {
|
|
1227
|
+
if (html[i] === '<') {
|
|
1228
|
+
const closeTag = html.indexOf('>', i);
|
|
1229
|
+
if (closeTag === -1)
|
|
1230
|
+
break;
|
|
1231
|
+
const tag = html.slice(i, closeTag + 1);
|
|
1232
|
+
if (tag.startsWith('<span')) {
|
|
1233
|
+
// Extract class: <span class="hljs-keyword"> or <span class="hljs-template-string">
|
|
1234
|
+
const classMatch = tag.match(/class="hljs-([\w-]+)"/);
|
|
1235
|
+
const cls = classMatch ? classMatch[1] : '';
|
|
1236
|
+
colorStack.push(theme[cls] ?? defaultColor);
|
|
1237
|
+
}
|
|
1238
|
+
else if (tag === '</span>') {
|
|
1239
|
+
if (colorStack.length > 1)
|
|
1240
|
+
colorStack.pop();
|
|
1241
|
+
}
|
|
1242
|
+
i = closeTag + 1;
|
|
1243
|
+
}
|
|
1244
|
+
else if (html[i] === '&') {
|
|
1245
|
+
// HTML entities: named (&), hex (=), decimal (`)
|
|
1246
|
+
const semi = html.indexOf(';', i);
|
|
1247
|
+
if (semi !== -1 && semi - i < 10) {
|
|
1248
|
+
const entity = html.slice(i, semi + 1);
|
|
1249
|
+
let ch;
|
|
1250
|
+
if (entity === '&')
|
|
1251
|
+
ch = '&';
|
|
1252
|
+
else if (entity === '<')
|
|
1253
|
+
ch = '<';
|
|
1254
|
+
else if (entity === '>')
|
|
1255
|
+
ch = '>';
|
|
1256
|
+
else if (entity === '"')
|
|
1257
|
+
ch = '"';
|
|
1258
|
+
else if (entity === ''' || entity === ''')
|
|
1259
|
+
ch = "'";
|
|
1260
|
+
else if (entity.startsWith('&#x'))
|
|
1261
|
+
ch = String.fromCodePoint(parseInt(entity.slice(3, -1), 16));
|
|
1262
|
+
else if (entity.startsWith('&#'))
|
|
1263
|
+
ch = String.fromCodePoint(parseInt(entity.slice(2, -1), 10));
|
|
1264
|
+
else
|
|
1265
|
+
ch = entity; // unknown named entity — keep as-is
|
|
1266
|
+
lines[lines.length - 1].push({ text: ch, color: colorStack[colorStack.length - 1] });
|
|
1267
|
+
i = semi + 1;
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
lines[lines.length - 1].push({ text: '&', color: colorStack[colorStack.length - 1] });
|
|
1271
|
+
i++;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
else if (html[i] === '\n') {
|
|
1275
|
+
lines.push([]);
|
|
1276
|
+
i++;
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
// Regular text — accumulate consecutive chars with same color
|
|
1280
|
+
const color = colorStack[colorStack.length - 1];
|
|
1281
|
+
let end = i + 1;
|
|
1282
|
+
while (end < html.length && html[end] !== '<' && html[end] !== '&' && html[end] !== '\n')
|
|
1283
|
+
end++;
|
|
1284
|
+
lines[lines.length - 1].push({ text: html.slice(i, end), color });
|
|
1285
|
+
i = end;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// Merge adjacent tokens with the same color on each line (fewer drawText calls)
|
|
1289
|
+
for (const line of lines) {
|
|
1290
|
+
for (let j = line.length - 1; j > 0; j--) {
|
|
1291
|
+
if (line[j].color === line[j - 1].color) {
|
|
1292
|
+
line[j - 1].text += line[j].text;
|
|
1293
|
+
line.splice(j, 1);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return lines;
|
|
1298
|
+
}
|
|
1014
1299
|
// ─── Text measurement (shared by all text-bearing elements) ──────────────────
|
|
1015
1300
|
/**
|
|
1016
1301
|
* Measure text with automatic word hyphenation (Liang's algorithm via hypher).
|