@boxpdf/html-writer 0.1.21 → 0.1.22

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/dist/index.js CHANGED
@@ -348,17 +348,20 @@ function number(value) {
348
348
  // src/visual-font.ts
349
349
  function visualFontAliases(pageNumber, fonts) {
350
350
  return new Map(
351
- fonts.filter((font) => font.format === "truetype" && !/(?:courier|^TTE)/i.test(font.family ?? "")).map((font) => [font.id, `boxpdf-${pageNumber}-${font.id}`])
351
+ fonts.filter(
352
+ (font) => (font.format === "truetype" || font.format === "opentype") && !/(?:courier|^TTE)/i.test(font.family ?? "")
353
+ ).map((font) => [font.id, `boxpdf-${pageNumber}-${font.id}`])
352
354
  );
353
355
  }
354
356
  function visualFontFace(font, aliases) {
355
- if (font.format !== "truetype") return "";
357
+ if (font.format !== "truetype" && font.format !== "opentype") return "";
356
358
  const alias = aliases.get(font.id);
357
359
  if (!alias) return "";
358
360
  const styles2 = visualFontStyles(font.family, alias).filter(
359
361
  (style) => !style.startsWith("font-family:")
360
362
  );
361
- return `@font-face{font-family:${alias};src:url(data:font/ttf;base64,${base64(font.data)}) format("truetype");${styles2.join(";")}}`;
363
+ const mime = font.format === "opentype" ? "font/otf" : "font/ttf";
364
+ return `@font-face{font-family:${alias};src:url(data:${mime};base64,${base64(font.data)}) format("${font.format}");${styles2.join(";")}}`;
362
365
  }
363
366
  function visualFontStyles(fontFamily, alias) {
364
367
  const normalized = fontFamily?.toLowerCase() ?? "";
@@ -440,7 +443,9 @@ function vectorMedia(page, imageOptions) {
440
443
  );
441
444
  const aliases = visualFontAliases(page.number, page.fonts ?? []);
442
445
  const visualCodeFonts = new Set(
443
- (page.fonts ?? []).filter((font) => font.format === "truetype" && font.visualCodeMapping).map((font) => font.id)
446
+ (page.fonts ?? []).filter(
447
+ (font) => (font.format === "truetype" || font.format === "opentype") && font.visualCodeMapping
448
+ ).map((font) => font.id)
444
449
  );
445
450
  return components.map((component, componentIndex) => {
446
451
  const bounds2 = component.bounds;
@@ -1293,7 +1298,8 @@ async function writeHtmlDocument(pages, write, options = {}) {
1293
1298
  );
1294
1299
  options.onSemanticStats?.(stats);
1295
1300
  } else {
1296
- for await (const page of pages) await writePage(page, write, options);
1301
+ const documentFonts = { entries: [] };
1302
+ for await (const page of pages) await writePositionedPage(page, write, options, documentFonts);
1297
1303
  }
1298
1304
  await write("</main>");
1299
1305
  if (includeDocument) await write("</body></html>");
@@ -1336,7 +1342,7 @@ async function pageToHtml(page, options = {}) {
1336
1342
  );
1337
1343
  return output;
1338
1344
  }
1339
- async function writePositionedPage(page, write, options) {
1345
+ async function writePositionedPage(page, write, options, documentFonts) {
1340
1346
  const imageOptions = resolveImageOptions("visual", options);
1341
1347
  const visualImages = await prepareVisualImages(page, imageOptions, options.onImage);
1342
1348
  const visualSpans = coalesceVisualSpans(page.visualSpans ?? page.spans);
@@ -1347,14 +1353,18 @@ async function writePositionedPage(page, write, options) {
1347
1353
  await write(
1348
1354
  `<section class="pdf-page pdf-page--visual pdf-page--positioned" data-page="${page.number}" data-rotate="${page.rotate}" style="width:${number3(displayWidth)}pt;height:${number3(displayHeight)}pt">`
1349
1355
  );
1350
- const fontAliases = visualFontAliases(page.number, page.fonts ?? []);
1356
+ const { aliases: fontAliases, fontsToEmit } = visualPageFonts(
1357
+ page.number,
1358
+ page.fonts ?? [],
1359
+ documentFonts
1360
+ );
1351
1361
  const type3Fonts = new Map(
1352
1362
  (page.fonts ?? []).filter((font) => font.format === "type3").map((font) => [font.id, font])
1353
1363
  );
1354
1364
  const textClasses = options.includeStyles ?? true ? visualTextClasses(page.number, visualSpans, fontAliases) : void 0;
1355
- if ((options.includeStyles ?? true) && page.fonts?.length) {
1365
+ if ((options.includeStyles ?? true) && fontsToEmit.length) {
1356
1366
  await write(
1357
- `<style>${page.fonts.map((font) => visualFontFace(font, fontAliases)).join("")}</style>`
1367
+ `<style>${fontsToEmit.map((font) => visualFontFace(font, fontAliases)).join("")}</style>`
1358
1368
  );
1359
1369
  }
1360
1370
  if (textClasses?.css) await write(`<style>${textClasses.css}</style>`);
@@ -1421,6 +1431,30 @@ async function writePositionedPage(page, write, options) {
1421
1431
  }
1422
1432
  await write("</div></section>");
1423
1433
  }
1434
+ function visualPageFonts(pageNumber, fonts, documentFonts) {
1435
+ const aliases = visualFontAliases(pageNumber, fonts);
1436
+ if (!documentFonts) return { aliases, fontsToEmit: fonts };
1437
+ const fontsToEmit = [];
1438
+ for (const font of fonts) {
1439
+ if (!aliases.has(font.id) || font.format === "type3") continue;
1440
+ const existing = documentFonts.entries.find(
1441
+ (entry) => entry.format === font.format && equalBytes(entry.data, font.data)
1442
+ );
1443
+ if (existing) {
1444
+ aliases.set(font.id, existing.alias);
1445
+ continue;
1446
+ }
1447
+ const alias = `boxpdf-document-font-${documentFonts.entries.length + 1}`;
1448
+ aliases.set(font.id, alias);
1449
+ documentFonts.entries.push({ alias, data: font.data, format: font.format });
1450
+ fontsToEmit.push(font);
1451
+ }
1452
+ return { aliases, fontsToEmit };
1453
+ }
1454
+ function equalBytes(left, right) {
1455
+ if (left.length !== right.length) return false;
1456
+ return left.every((value, index) => value === right[index]);
1457
+ }
1424
1458
  function coalesceVisualSpans(spans) {
1425
1459
  const output = [];
1426
1460
  for (const span of spans) {
@@ -1446,11 +1480,14 @@ function canCoalesceVisualSpans(left, right) {
1446
1480
  if (left.direction !== "ltr" || right.direction !== "ltr") return false;
1447
1481
  if (/guardian/i.test(left.fontFamily ?? "")) return false;
1448
1482
  if (left.glyphCodes || right.glyphCodes) return false;
1483
+ if (left.fontAssetId || right.fontAssetId) return false;
1449
1484
  if (!sameVisualTextState(left, right)) return false;
1450
1485
  const tolerance = Math.max(0.02, left.fontSize * 0.015);
1451
1486
  if (Math.abs(left.bounds.y - right.bounds.y) > tolerance) return false;
1452
1487
  const gap = right.bounds.x - (left.bounds.x + left.bounds.width);
1453
- return !right.hasLeadingSpace && gap >= -tolerance && gap <= tolerance;
1488
+ if (right.hasLeadingSpace) return false;
1489
+ if (gap >= -tolerance && gap <= tolerance) return true;
1490
+ return right.textAdjustmentBefore !== void 0 && right.textAdjustmentBefore < 0 && Math.abs(right.textAdjustmentBefore - gap) <= 1e-3;
1454
1491
  }
1455
1492
  function sameVisualTextState(left, right) {
1456
1493
  return Math.abs(left.fontSize - right.fontSize) <= 1e-3 && left.fontName === right.fontName && left.fontFamily === right.fontFamily && left.fontAssetId === right.fontAssetId && left.color === right.color && left.fillOpacity === right.fillOpacity && left.strokeColor === right.strokeColor && left.strokeWidth === right.strokeWidth && left.strokeOpacity === right.strokeOpacity && left.renderingMode === right.renderingMode && sameTransform(left.transform, right.transform);
@@ -1491,6 +1528,13 @@ function visualTextLine(spans, startIndex, styleClasses, pageHeight, fontAliases
1491
1528
  if (endIndex === startIndex) return void 0;
1492
1529
  const baseline = pageHeight - first.bounds.y;
1493
1530
  const lineSpans = spans.slice(startIndex, endIndex + 1);
1531
+ const dxRun = visualDxTextRun(lineSpans, fontAliases);
1532
+ if (dxRun) {
1533
+ return {
1534
+ html: `<text class="${className}" x="${number3(first.bounds.x)}" y="${number3(baseline)}" dx="${dxRun.offsets.map(number3).join(" ")}">${escapeHtml4(dxRun.text)}</text>`,
1535
+ endIndex
1536
+ };
1537
+ }
1494
1538
  const content = lineSpans.map(
1495
1539
  (span, index) => visualTextTspan(span, index > 0 ? textSpanGap(lineSpans[index - 1], span) : void 0)
1496
1540
  ).join("");
@@ -1499,6 +1543,32 @@ function visualTextLine(spans, startIndex, styleClasses, pageHeight, fontAliases
1499
1543
  endIndex
1500
1544
  };
1501
1545
  }
1546
+ function visualDxTextRun(spans, fontAliases) {
1547
+ const first = spans[0];
1548
+ if (!first?.fontAssetId || !fontAliases.has(first.fontAssetId) || spans.length < 2)
1549
+ return void 0;
1550
+ let text = first.text;
1551
+ const offsets = characterOffsets(first, 0);
1552
+ for (let index = 1; index < spans.length; index += 1) {
1553
+ const previous = spans[index - 1];
1554
+ const current = spans[index];
1555
+ if (!previous || !current || current.fontAssetId !== first.fontAssetId) return void 0;
1556
+ const characters = [...current.text];
1557
+ if (characters.length === 0 || previous.naturalWidth === void 0) return void 0;
1558
+ const gap = current.bounds.x - (previous.bounds.x + previous.bounds.width);
1559
+ const trailingSpacing = (previous.characterSpacing ?? 0) + (previous.text.endsWith(" ") ? previous.wordSpacing ?? 0 : 0);
1560
+ offsets.push(...characterOffsets(current, gap + trailingSpacing));
1561
+ text += current.text;
1562
+ }
1563
+ while (offsets.at(-1) === 0) offsets.pop();
1564
+ return offsets.length > 0 ? { text, offsets } : void 0;
1565
+ }
1566
+ function characterOffsets(span, first) {
1567
+ const characters = [...span.text];
1568
+ return characters.map(
1569
+ (_, index) => index === 0 ? first : (span.characterSpacing ?? 0) + (characters[index - 1] === " " ? span.wordSpacing ?? 0 : 0)
1570
+ );
1571
+ }
1502
1572
  function visualTextTspan(span, dx) {
1503
1573
  const extent = span.bounds.width;
1504
1574
  const offset = dx === void 0 || number3(dx) === "0" ? "" : ` dx="${number3(dx)}"`;