@stll/folio-core 0.49.0 → 0.50.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.
Files changed (91) hide show
  1. package/dist/ai-edits/headless.d.ts +37 -1
  2. package/dist/ai-edits/headless.js +47 -16
  3. package/dist/display-list/dom/renderDisplayListToDom.d.ts +10 -1
  4. package/dist/display-list/dom/renderDisplayListToDom.js +8 -7
  5. package/dist/display-list/html/renderDisplayListToHtml.d.ts +21 -0
  6. package/dist/display-list/html/renderDisplayListToHtml.js +126 -0
  7. package/dist/display-list/selectDisplayPages.d.ts +6 -0
  8. package/dist/display-list/selectDisplayPages.js +44 -0
  9. package/dist/export-pdf.d.ts +35 -2
  10. package/dist/export-pdf.js +51 -20
  11. package/dist/fonts/fontsourceFaces.d.ts +47 -0
  12. package/dist/fonts/fontsourceFaces.js +230 -0
  13. package/dist/layout-bridge/convert/flowBlockPostprocessing.d.ts +8 -0
  14. package/dist/layout-bridge/convert/flowBlockPostprocessing.js +106 -0
  15. package/dist/layout-bridge/convert/flowBorders.d.ts +17 -0
  16. package/dist/layout-bridge/convert/flowBorders.js +31 -0
  17. package/dist/layout-bridge/convert/flowConversionShared.d.ts +111 -0
  18. package/dist/layout-bridge/convert/flowConversionShared.js +23 -0
  19. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -1
  20. package/dist/layout-bridge/convert/footnoteLayout.js +2 -1
  21. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +2 -1
  22. package/dist/layout-bridge/convert/imageConversion.d.ts +33 -0
  23. package/dist/layout-bridge/convert/imageConversion.js +118 -0
  24. package/dist/layout-bridge/convert/listMarkers.d.ts +28 -0
  25. package/dist/layout-bridge/convert/listMarkers.js +98 -0
  26. package/dist/layout-bridge/convert/noteReferences.d.ts +14 -0
  27. package/dist/layout-bridge/convert/noteReferences.js +29 -0
  28. package/dist/layout-bridge/convert/pageBreakSplitting.d.ts +16 -0
  29. package/dist/layout-bridge/convert/pageBreakSplitting.js +140 -0
  30. package/dist/layout-bridge/convert/paragraphAttrs.d.ts +20 -0
  31. package/dist/layout-bridge/convert/paragraphAttrs.js +234 -0
  32. package/dist/layout-bridge/convert/paragraphConversion.d.ts +12 -0
  33. package/dist/layout-bridge/convert/paragraphConversion.js +112 -0
  34. package/dist/layout-bridge/convert/paragraphRuns.d.ts +15 -0
  35. package/dist/layout-bridge/convert/paragraphRuns.js +281 -0
  36. package/dist/layout-bridge/convert/runFormattingMerge.d.ts +19 -0
  37. package/dist/layout-bridge/convert/runFormattingMerge.js +167 -0
  38. package/dist/layout-bridge/convert/runMarkFormatting.d.ts +11 -0
  39. package/dist/layout-bridge/convert/runMarkFormatting.js +291 -0
  40. package/dist/layout-bridge/convert/sectionBoundaries.d.ts +35 -0
  41. package/dist/layout-bridge/convert/sectionBoundaries.js +114 -0
  42. package/dist/layout-bridge/convert/tableConversion.d.ts +12 -0
  43. package/dist/layout-bridge/convert/tableConversion.js +303 -0
  44. package/dist/layout-bridge/convert/textBoxFill.d.ts +20 -0
  45. package/dist/layout-bridge/convert/textBoxFill.js +42 -0
  46. package/dist/layout-bridge/convert/textFormattingConversion.d.ts +22 -0
  47. package/dist/layout-bridge/convert/textFormattingConversion.js +120 -0
  48. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +5 -113
  49. package/dist/layout-bridge/convert/toFlowBlocks.js +11 -2082
  50. package/dist/layout-bridge/engine/hitTest.js +1 -1
  51. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  52. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  53. package/dist/layout-bridge/engine/selectionRects.js +1 -1
  54. package/dist/layout-engine/imageLayout.d.ts +9 -0
  55. package/dist/layout-engine/imageLayout.js +48 -0
  56. package/dist/layout-engine/index.d.ts +3 -18
  57. package/dist/layout-engine/index.js +12 -937
  58. package/dist/layout-engine/layoutFlowShared.d.ts +6 -0
  59. package/dist/layout-engine/layoutFlowShared.js +8 -0
  60. package/dist/layout-engine/measure/crossRunWords.d.ts +45 -0
  61. package/dist/layout-engine/measure/crossRunWords.js +172 -0
  62. package/dist/layout-engine/measure/floatingLineClearance.d.ts +24 -0
  63. package/dist/layout-engine/measure/floatingLineClearance.js +44 -0
  64. package/dist/layout-engine/measure/inlineObjectMetrics.d.ts +6 -0
  65. package/dist/layout-engine/measure/inlineObjectMetrics.js +52 -0
  66. package/dist/layout-engine/measure/justification.d.ts +32 -0
  67. package/dist/layout-engine/measure/justification.js +83 -0
  68. package/dist/layout-engine/measure/lineFitting.d.ts +48 -0
  69. package/dist/layout-engine/measure/lineFitting.js +145 -0
  70. package/dist/layout-engine/measure/lineTypography.d.ts +44 -0
  71. package/dist/layout-engine/measure/lineTypography.js +124 -0
  72. package/dist/layout-engine/measure/measureBlocks.js +2 -1
  73. package/dist/layout-engine/measure/measureParagraph.d.ts +1 -20
  74. package/dist/layout-engine/measure/measureParagraph.js +32 -734
  75. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +109 -0
  76. package/dist/layout-engine/measure/paragraphMeasureShared.js +82 -0
  77. package/dist/layout-engine/measure/paragraphTabs.d.ts +18 -0
  78. package/dist/layout-engine/measure/paragraphTabs.js +78 -0
  79. package/dist/layout-engine/paragraphLayout.d.ts +23 -0
  80. package/dist/layout-engine/paragraphLayout.js +204 -0
  81. package/dist/layout-engine/sectionLayout.d.ts +45 -0
  82. package/dist/layout-engine/sectionLayout.js +216 -0
  83. package/dist/layout-engine/tableLayout.d.ts +17 -0
  84. package/dist/layout-engine/tableLayout.js +369 -0
  85. package/dist/layout-engine/textBoxLayout.d.ts +15 -0
  86. package/dist/layout-engine/textBoxLayout.js +137 -0
  87. package/dist/layout-painter/renderPage.js +1 -0
  88. package/dist/paged-layout/sectionBlockWidths.d.ts +2 -1
  89. package/dist/paged-layout/sectionBlockWidths.js +1 -1
  90. package/dist/server.d.ts +3 -2
  91. package/package.json +2 -2
@@ -35,6 +35,34 @@ type FolioApplyOperationsOptions = {
35
35
  };
36
36
  /** Options for {@link FolioDocxReviewer.applyDocumentOperations}. */
37
37
  type FolioApplyDocumentOperationsOptions = Omit<FolioApplyOperationsOptions, "mode">;
38
+ /**
39
+ * Why a save rewrote the whole package instead of patching the edited
40
+ * paragraphs into the original: `structuralChange` (paragraphs added or
41
+ * removed, or styles, headers, footers or final section properties staged),
42
+ * `untrackedChange` (an edit the change tracker cannot key to a paragraph), or
43
+ * `selectiveDeclined` (the patch could not express the edit safely).
44
+ */
45
+ type FolioReviewerRepackReason = "structuralChange" | "untrackedChange" | "selectiveDeclined";
46
+ /** Options for {@link FolioDocxReviewer.save}. */
47
+ type FolioReviewerSaveOptions = {
48
+ /**
49
+ * `"allow"` (default) falls back to a full repack when the selective patch
50
+ * does not apply; `"refuse"` reports the reason and writes nothing.
51
+ */
52
+ repack?: "allow" | "refuse";
53
+ };
54
+ /** Result of {@link FolioDocxReviewer.save}. */
55
+ type FolioReviewerSaveResult = {
56
+ type: "selective";
57
+ buffer: ArrayBuffer;
58
+ } | {
59
+ type: "full-repack";
60
+ reason: FolioReviewerRepackReason;
61
+ buffer: ArrayBuffer;
62
+ } | {
63
+ type: "repackRefused";
64
+ reason: FolioReviewerRepackReason;
65
+ };
38
66
  /** Options for {@link FolioDocxReviewer.getContentAsText}. */
39
67
  type FolioGetContentAsTextOptions = {
40
68
  /**
@@ -248,6 +276,8 @@ type FolioReviewReplyInput = {
248
276
  /** Reply author; defaults to the reviewer's author. */
249
277
  author?: string;
250
278
  initials?: string;
279
+ /** ISO-8601 reply date; defaults to the wall clock. */
280
+ date?: string;
251
281
  };
252
282
  /** A comment thread discovered in the document. */
253
283
  type FolioReviewComment = {
@@ -506,6 +536,12 @@ declare class FolioDocxReviewer {
506
536
  * structural edits — the same two-tier path the editor's save uses.
507
537
  */
508
538
  toBuffer(): Promise<ArrayBuffer>;
539
+ /**
540
+ * Serialise like {@link toBuffer} and report which path wrote the package.
541
+ * With `repack: "refuse"`, a save the selective patch cannot express
542
+ * returns the reason instead of rewriting every part.
543
+ */
544
+ save({ repack: repackPolicy }?: FolioReviewerSaveOptions): Promise<FolioReviewerSaveResult>;
509
545
  private assertResolvedStoriesSerialized;
510
546
  /**
511
547
  * Attempt the selective patch, treating a throw the same as a decline. Odd
@@ -580,4 +616,4 @@ type ApplyFolioAIEditsToBufferResult = FolioAIEditApplyResult & {
580
616
  */
581
617
  declare const applyFolioAIEditsToBuffer: (buffer: ArrayBuffer, operations: FolioAIEditOperation[], options?: ApplyFolioAIEditsToBufferOptions) => Promise<ApplyFolioAIEditsToBufferResult>;
582
618
  //#endregion
583
- export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
619
+ export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, FolioReviewerRepackReason, FolioReviewerSaveOptions, FolioReviewerSaveResult, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
@@ -68,16 +68,10 @@ import JSZip from "jszip";
68
68
  */
69
69
  let commentIdCursor = Date.now();
70
70
  let undoHandleCursor = Date.now();
71
- /**
72
- * Build the note-free comment thread the apply layer references by id.
73
- * Mirrors `commentsHelpers.createComment` (a pure object literal there) so a
74
- * headless `commentOnBlock` / `comment` op serialises the same `comments.xml`
75
- * shape the editor produces.
76
- */
77
- const createReviewerComment = (id, text, author) => ({
71
+ const createReviewerComment = ({ id, text, author, date }) => ({
78
72
  id,
79
73
  author,
80
- date: (/* @__PURE__ */ new Date()).toISOString(),
74
+ date: date ?? (/* @__PURE__ */ new Date()).toISOString(),
81
75
  content: [{
82
76
  type: "paragraph",
83
77
  formatting: {},
@@ -848,7 +842,12 @@ var FolioDocxReviewer = class FolioDocxReviewer {
848
842
  ...tableTemplates !== void 0 && { tableTemplates },
849
843
  ...replacementBackground !== void 0 && { replacementBackground },
850
844
  createCommentId: (text) => {
851
- const comment = createReviewerComment(this.nextCommentId(), text, this.author);
845
+ const comment = createReviewerComment({
846
+ id: this.nextCommentId(),
847
+ text,
848
+ author: this.author,
849
+ date: revisionStamp?.date
850
+ });
852
851
  this.createdComments.push(comment);
853
852
  return comment.id;
854
853
  },
@@ -1080,7 +1079,8 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1080
1079
  const reply = createReply([...this.baseDocument.package.document.comments ?? [], ...this.createdComments], parentId, {
1081
1080
  author: input.author ?? this.author,
1082
1081
  text: input.text,
1083
- ...input.initials !== void 0 ? { initials: input.initials } : {}
1082
+ ...input.initials !== void 0 ? { initials: input.initials } : {},
1083
+ ...input.date !== void 0 && { date: input.date }
1084
1084
  });
1085
1085
  if (!reply) return null;
1086
1086
  this.createdComments.push(reply);
@@ -1344,12 +1344,21 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1344
1344
  structuralChange ||= hasStructuralChanges(entry.state);
1345
1345
  untrackedChanges ||= hasUntrackedChanges(entry.state);
1346
1346
  }
1347
+ let path = {
1348
+ type: "selective-first",
1349
+ changedParaIds
1350
+ };
1351
+ if (structuralChange) path = {
1352
+ type: "full-repack",
1353
+ reason: "structuralChange"
1354
+ };
1355
+ else if (untrackedChanges) path = {
1356
+ type: "full-repack",
1357
+ reason: "untrackedChange"
1358
+ };
1347
1359
  return {
1348
1360
  document: this.documentFromStateSnapshot(snapshot),
1349
- path: structuralChange || untrackedChanges ? { type: "full-repack" } : {
1350
- type: "selective-first",
1351
- changedParaIds
1352
- },
1361
+ path,
1353
1362
  changedNoteParaIds,
1354
1363
  sectionReferenceRemovals: snapshot.sectionReferenceRemovals,
1355
1364
  sectionEndpointRemoval: getTrackedSectionEndpointRemoval(snapshot.mainState),
@@ -1363,12 +1372,30 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1363
1372
  * structural edits — the same two-tier path the editor's save uses.
1364
1373
  */
1365
1374
  async toBuffer() {
1375
+ const result = await this.save();
1376
+ if (result.type === "repackRefused") return panic("A save that allows a full repack refused to repack", { result });
1377
+ return result.buffer;
1378
+ }
1379
+ /**
1380
+ * Serialise like {@link toBuffer} and report which path wrote the package.
1381
+ * With `repack: "refuse"`, a save the selective patch cannot express
1382
+ * returns the reason instead of rewriting every part.
1383
+ */
1384
+ async save({ repack: repackPolicy = "allow" } = {}) {
1366
1385
  const save = this.captureSaveSnapshot();
1367
1386
  const selective = save.path.type === "selective-first" ? await this.trySelectiveSave(save.document, save.path.changedParaIds) : null;
1368
1387
  if (selective) {
1369
1388
  await this.assertResolvedStoriesSerialized(selective, save.resolvedStoryExpectations);
1370
- return selective;
1389
+ return {
1390
+ type: "selective",
1391
+ buffer: selective
1392
+ };
1371
1393
  }
1394
+ const reason = save.path.type === "full-repack" ? save.path.reason : "selectiveDeclined";
1395
+ if (repackPolicy === "refuse") return {
1396
+ type: "repackRefused",
1397
+ reason
1398
+ };
1372
1399
  const repackDocument = {
1373
1400
  ...save.document,
1374
1401
  originalBuffer: this.originalBuffer
@@ -1385,7 +1412,11 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1385
1412
  repack: repackReferences
1386
1413
  }) : await repackReferences();
1387
1414
  await this.assertResolvedStoriesSerialized(buffer, save.resolvedStoryExpectations);
1388
- return buffer;
1415
+ return {
1416
+ type: "full-repack",
1417
+ reason,
1418
+ buffer
1419
+ };
1389
1420
  }
1390
1421
  async assertResolvedStoriesSerialized(buffer, expectations) {
1391
1422
  if (expectations.length === 0) return;
@@ -1,10 +1,19 @@
1
1
  import { DisplayColor, DisplayFontFace, DisplayImageSource, DisplayList, DisplayPage } from "../types.js";
2
2
  //#region src/display-list/dom/renderDisplayListToDom.d.ts
3
+ /**
4
+ * How the backend addresses image and embedded-font bytes: `objectUrl` mints
5
+ * a `blob:` URL where the runtime can (a live page, which revokes them), and
6
+ * `dataUrl` always inlines the bytes (markup that leaves the process, such as
7
+ * a serialized page, where a `blob:` URL would resolve to nothing).
8
+ */
9
+ type DisplayBinaryUrls = "objectUrl" | "dataUrl";
3
10
  type RenderDisplayListOptions = {
4
11
  /** Document to create elements in. */
5
12
  readonly doc: Document;
6
13
  /** Page background, painted before any primitive. */
7
14
  readonly pageBackground?: DisplayColor;
15
+ /** Defaults to `objectUrl`. */
16
+ readonly binaryUrls?: DisplayBinaryUrls;
8
17
  };
9
18
  /**
10
19
  * A page's glyph runs and images index tables that live on the list, and an
@@ -23,4 +32,4 @@ declare const renderDisplayPageToDom: (page: DisplayPage, options: RenderDisplay
23
32
  /** One `div.layout-page` per display page, in page order. */
24
33
  declare const renderDisplayListToDom: (list: DisplayList, options: RenderDisplayListOptions) => HTMLElement[];
25
34
  //#endregion
26
- export { RenderDisplayListOptions, RenderDisplayPageOptions, renderDisplayListToDom, renderDisplayPageToDom };
35
+ export { DisplayBinaryUrls, RenderDisplayListOptions, RenderDisplayPageOptions, renderDisplayListToDom, renderDisplayPageToDom };
@@ -103,8 +103,8 @@ const resolveImage = (ref, images) => {
103
103
  };
104
104
  /** A `Blob` part cannot be backed by shared memory, so prove it is not. */
105
105
  const isBlobPart = (bytes) => bytes.buffer instanceof ArrayBuffer;
106
- const binarySrc = (bytes, mimeType) => {
107
- if (isBlobPart(bytes) && typeof Blob === "function" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
106
+ const binarySrc = (bytes, mimeType, urls) => {
107
+ if (urls === "objectUrl" && isBlobPart(bytes) && typeof Blob === "function" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
108
108
  return bytesToDataUrl(bytes, mimeType);
109
109
  };
110
110
  const fontFaceRule = (face, embedded, src) => `@font-face { font-family: ${quoteFontFamily(embedded.id)}; src: url("${src}"); font-weight: ${face.weight}; font-style: ${face.italic ? "italic" : "normal"}; }`;
@@ -113,14 +113,14 @@ const fontFaceRule = (face, embedded, src) => `@font-face { font-family: ${quote
113
113
  * once per render and shared by every page: an id maps to one blob, so the
114
114
  * bytes are held once and one revoke releases them.
115
115
  */
116
- const embeddedFontFaceCss = (fonts) => {
116
+ const embeddedFontFaceCss = (fonts, urls) => {
117
117
  const srcById = /* @__PURE__ */ new Map();
118
118
  const rules = [];
119
119
  for (const face of fonts) {
120
120
  const embedded = face.embedded;
121
121
  if (embedded === void 0) continue;
122
122
  const cached = srcById.get(embedded.id);
123
- const src = cached ?? binarySrc(embedded.bytes, FONT_MIME_TYPE);
123
+ const src = cached ?? binarySrc(embedded.bytes, FONT_MIME_TYPE, urls);
124
124
  if (cached === void 0) srcById.set(embedded.id, src);
125
125
  rules.push(fontFaceRule(face, embedded, src));
126
126
  }
@@ -399,7 +399,7 @@ const paintImage = ({ image, rect, crop, opacity, brightness, contrast }, contex
399
399
  element.style.height = px(fullHeight);
400
400
  }
401
401
  element.alt = "";
402
- element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format]);
402
+ element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format], context.binaryUrls);
403
403
  clip.append(element);
404
404
  context.parent.append(clip);
405
405
  };
@@ -497,6 +497,7 @@ const renderPage = (page, options) => {
497
497
  }
498
498
  const context = {
499
499
  doc: options.doc,
500
+ binaryUrls: options.binaryUrls ?? "objectUrl",
500
501
  fonts: options.fonts,
501
502
  images: options.images,
502
503
  parent: element,
@@ -595,11 +596,11 @@ const paintRegionTree = ({ primitives, regions }, context) => {
595
596
  };
596
597
  const renderDisplayPageToDom = (page, options) => renderPage(page, {
597
598
  ...options,
598
- fontFaceCss: embeddedFontFaceCss(options.fonts)
599
+ fontFaceCss: embeddedFontFaceCss(options.fonts, options.binaryUrls ?? "objectUrl")
599
600
  });
600
601
  /** One `div.layout-page` per display page, in page order. */
601
602
  const renderDisplayListToDom = (list, options) => {
602
- const fontFaceCss = embeddedFontFaceCss(list.fonts);
603
+ const fontFaceCss = embeddedFontFaceCss(list.fonts, options.binaryUrls ?? "objectUrl");
603
604
  return list.pages.map((page, pageIndex) => renderPage(page, {
604
605
  ...options,
605
606
  fonts: list.fonts,
@@ -0,0 +1,21 @@
1
+ import { DisplayColor, DisplayList } from "../types.js";
2
+ //#region src/display-list/html/renderDisplayListToHtml.d.ts
3
+ declare const escapeHtmlText: (value: string) => string;
4
+ type RenderDisplayListToHtmlOptions = {
5
+ /** `@font-face` rules for the families the list names, placed in the head. */
6
+ readonly fontFaceCss?: string;
7
+ /** The document title. */
8
+ readonly title?: string;
9
+ /** Space between pages. Defaults to 0: pages stack flush. */
10
+ readonly pageGapPx?: number;
11
+ /** CSS color behind the pages. Defaults to white. */
12
+ readonly canvasColor?: string;
13
+ /** Page background, painted before any primitive. */
14
+ readonly pageBackground?: DisplayColor;
15
+ };
16
+ /** The pages' markup, one slot per display page, in page order. */
17
+ declare const renderDisplayListPagesToHtml: (list: DisplayList, options?: Pick<RenderDisplayListToHtmlOptions, "pageBackground">) => string[];
18
+ /** A complete HTML document showing every page of the list. */
19
+ declare const renderDisplayListToHtml: (list: DisplayList, options?: RenderDisplayListToHtmlOptions) => string;
20
+ //#endregion
21
+ export { RenderDisplayListToHtmlOptions, escapeHtmlText, renderDisplayListPagesToHtml, renderDisplayListToHtml };
@@ -0,0 +1,126 @@
1
+ import { renderDisplayListToDom } from "../dom/renderDisplayListToDom.js";
2
+ //#region src/display-list/html/renderDisplayListToHtml.ts
3
+ /**
4
+ * A display list as one HTML document, with no browser and no DOM.
5
+ *
6
+ * The DOM backend paints into whatever `Document` it is handed. This module
7
+ * hands it a document with only what the backend reaches for and serializes
8
+ * the result, so a server, a CLI or a harness can produce the page a browser
9
+ * would show from the same painter the editor uses. Binary data (images,
10
+ * embedded faces) is inlined as `data:` URLs, because a `blob:` URL minted in
11
+ * this process resolves to nothing once the markup leaves it.
12
+ */
13
+ const VOID_TAGS = /* @__PURE__ */ new Set([
14
+ "img",
15
+ "br",
16
+ "hr"
17
+ ]);
18
+ const createStubElement = (tagName) => {
19
+ const children = [];
20
+ const attributes = {};
21
+ return {
22
+ tagName,
23
+ style: {},
24
+ dataset: {},
25
+ attributes,
26
+ children,
27
+ className: "",
28
+ id: "",
29
+ textContent: "",
30
+ alt: "",
31
+ src: "",
32
+ href: "",
33
+ title: "",
34
+ append: (...nodes) => {
35
+ children.push(...nodes);
36
+ },
37
+ appendChild: (node) => {
38
+ children.push(node);
39
+ return node;
40
+ },
41
+ setAttribute: (name, value) => {
42
+ attributes[name] = value;
43
+ }
44
+ };
45
+ };
46
+ /**
47
+ * A `Document` with only what the DOM backend reaches for. A backend that
48
+ * grows a new requirement throws on the missing property here rather than
49
+ * silently serializing less than it painted.
50
+ */
51
+ const stubDocument = () => {
52
+ return { createElement: createStubElement };
53
+ };
54
+ const asStub = (element) => element;
55
+ /** A vendor property is camelCase with no leading capital, so its leading dash is explicit. */
56
+ const VENDOR_PREFIXES = [
57
+ "webkit-",
58
+ "moz-",
59
+ "ms-",
60
+ "o-"
61
+ ];
62
+ const toKebabCase = (name) => {
63
+ const hyphenated = name.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
64
+ return VENDOR_PREFIXES.some((prefix) => hyphenated.startsWith(prefix)) ? `-${hyphenated}` : hyphenated;
65
+ };
66
+ const escapeHtmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
67
+ const escapeAttribute = (value) => escapeHtmlText(value).replaceAll("\"", "&quot;");
68
+ const serializeStyle = (style) => Object.entries(style).map(([property, value]) => `${toKebabCase(property)}: ${value}`).join("; ");
69
+ /**
70
+ * Serialized without one character of added whitespace: the backend sets
71
+ * `white-space: pre` on every run, so an indentation newline would be painted.
72
+ */
73
+ const RAW_TEXT_TAGS = /* @__PURE__ */ new Set(["style", "script"]);
74
+ /** Text placed inside a `<style>` or `<script>`: a `</` cannot close the element early. */
75
+ const rawElementText = (text) => text.replaceAll("</", "<\\/");
76
+ const serializeElement = (element) => {
77
+ const attributes = [];
78
+ if (element.className !== "") attributes.push(`class="${escapeAttribute(element.className)}"`);
79
+ if (element.id !== "") attributes.push(`id="${escapeAttribute(element.id)}"`);
80
+ if (element.href !== "") attributes.push(`href="${escapeAttribute(element.href)}"`);
81
+ if (element.src !== "") attributes.push(`src="${escapeAttribute(element.src)}"`);
82
+ if (element.title !== "") attributes.push(`title="${escapeAttribute(element.title)}"`);
83
+ if (element.tagName === "img") attributes.push(`alt="${escapeAttribute(element.alt)}"`);
84
+ for (const [name, value] of Object.entries(element.attributes)) attributes.push(`${name}="${escapeAttribute(value)}"`);
85
+ for (const [key, value] of Object.entries(element.dataset)) attributes.push(`data-${toKebabCase(key)}="${escapeAttribute(value)}"`);
86
+ const style = serializeStyle(element.style);
87
+ if (style !== "") attributes.push(`style="${escapeAttribute(style)}"`);
88
+ const open = [element.tagName, ...attributes].join(" ");
89
+ if (VOID_TAGS.has(element.tagName)) return `<${open} />`;
90
+ if (element.children.length > 0) return `<${open}>${element.children.map(serializeElement).join("")}</${element.tagName}>`;
91
+ return `<${open}>${RAW_TEXT_TAGS.has(element.tagName) ? rawElementText(element.textContent) : escapeHtmlText(element.textContent)}</${element.tagName}>`;
92
+ };
93
+ /**
94
+ * Each page sits in a slot of whole pixels. A page height is fractional (A4 is
95
+ * 1122.52 px), so pages stacked in normal flow would start at fractional
96
+ * offsets; the slot rounds the offset while the page keeps its exact size.
97
+ */
98
+ const pageSlot = (markup, page) => `<div class="layout-page-slot" style="position: relative; overflow: hidden; width: ${String(Math.ceil(page.widthPx))}px; height: ${String(Math.ceil(page.heightPx))}px">${markup}</div>`;
99
+ /** The pages' markup, one slot per display page, in page order. */
100
+ const renderDisplayListPagesToHtml = (list, options = {}) => renderDisplayListToDom(list, {
101
+ doc: stubDocument(),
102
+ binaryUrls: "dataUrl",
103
+ ...options.pageBackground !== void 0 && { pageBackground: options.pageBackground }
104
+ }).map((element, index) => {
105
+ const markup = serializeElement(asStub(element));
106
+ const page = list.pages.at(index);
107
+ return page === void 0 ? markup : pageSlot(markup, page);
108
+ });
109
+ /** A complete HTML document showing every page of the list. */
110
+ const renderDisplayListToHtml = (list, options = {}) => {
111
+ const gap = options.pageGapPx ?? 0;
112
+ const canvas = rawElementText(options.canvasColor ?? "#fff");
113
+ const layout = gap > 0 ? `body { display: flex; flex-direction: column; align-items: center; gap: ${String(gap)}px; padding: ${String(gap)}px 0; }` : "";
114
+ return [
115
+ "<!doctype html>",
116
+ `<html><head><meta charset="utf-8"><title>${escapeHtmlText(options.title ?? "")}</title><style>`,
117
+ rawElementText(options.fontFaceCss ?? ""),
118
+ `html, body { margin: 0; padding: 0; background: ${canvas}; }`,
119
+ layout,
120
+ "</style></head><body>",
121
+ renderDisplayListPagesToHtml(list, options).join(""),
122
+ "</body></html>"
123
+ ].join("\n");
124
+ };
125
+ //#endregion
126
+ export { escapeHtmlText, renderDisplayListPagesToHtml, renderDisplayListToHtml };
@@ -0,0 +1,6 @@
1
+ import { DisplayList } from "./types.js";
2
+ //#region src/display-list/selectDisplayPages.d.ts
3
+ /** Keep `pageIndices` (0-based, in the order given; duplicates are dropped). */
4
+ declare const selectDisplayPages: (list: DisplayList, pageIndices: readonly number[]) => DisplayList;
5
+ //#endregion
6
+ export { selectDisplayPages };
@@ -0,0 +1,44 @@
1
+ //#region src/display-list/selectDisplayPages.ts
2
+ /** Keep `pageIndices` (0-based, in the order given; duplicates are dropped). */
3
+ const selectDisplayPages = (list, pageIndices) => {
4
+ const kept = [...new Set(pageIndices)].filter((index) => Number.isInteger(index) && index >= 0 && index < list.pages.length);
5
+ const remap = new Map(kept.map((original, next) => [original, next]));
6
+ const relink = (link) => {
7
+ if (link.target.kind !== "page") return [link];
8
+ const pageIndex = remap.get(link.target.pageIndex);
9
+ return pageIndex === void 0 ? [] : [{
10
+ ...link,
11
+ target: {
12
+ ...link.target,
13
+ pageIndex
14
+ }
15
+ }];
16
+ };
17
+ const pages = kept.flatMap((index) => {
18
+ const page = list.pages[index];
19
+ return page === void 0 ? [] : [{
20
+ ...page,
21
+ links: page.links.flatMap(relink)
22
+ }];
23
+ });
24
+ return {
25
+ ...list,
26
+ pages,
27
+ outline: list.outline.flatMap((entry) => {
28
+ const pageIndex = remap.get(entry.pageIndex);
29
+ return pageIndex === void 0 ? [] : [{
30
+ ...entry,
31
+ pageIndex
32
+ }];
33
+ }),
34
+ unsupported: list.unsupported.flatMap((entry) => {
35
+ const pageIndex = remap.get(entry.pageIndex);
36
+ return pageIndex === void 0 ? [] : [{
37
+ ...entry,
38
+ pageIndex
39
+ }];
40
+ })
41
+ };
42
+ };
43
+ //#endregion
44
+ export { selectDisplayPages };
@@ -1,5 +1,5 @@
1
1
  import { DocxInput } from "./utils/docxInput.js";
2
- import { DisplayMetadata, DisplayUnsupported } from "./display-list/types.js";
2
+ import { DisplayList, DisplayMetadata, DisplayUnsupported } from "./display-list/types.js";
3
3
  import { HeadlessFontSource, HeadlessFontSubstitution } from "./fonts/headlessMeasure.js";
4
4
  import { HeadlessLayoutGap } from "./headless-layout.js";
5
5
  import { PdfSubstitution } from "./pdf/fonts.js";
@@ -23,6 +23,11 @@ type ExportDocxToPdfOptions = {
23
23
  readonly timestamp: string;
24
24
  readonly metadata?: DisplayMetadata;
25
25
  readonly producer?: string;
26
+ /**
27
+ * 0-based indices of the pages to write, in order. Links and outline
28
+ * entries to other pages are dropped. Omit for every page.
29
+ */
30
+ readonly pages?: readonly number[];
26
31
  };
27
32
  type ExportDocxToPdfResult = {
28
33
  readonly bytes: Uint8Array;
@@ -38,6 +43,34 @@ type ExportDocxToPdfResult = {
38
43
  /** Code points painted as `.notdef` because no supplied face covers them. */
39
44
  readonly unencodable: readonly PdfUnencodable[];
40
45
  };
46
+ type BuildDocxDisplayListOptions = {
47
+ /** Supplies face binaries for measurement. */
48
+ readonly fonts: HeadlessFontSource;
49
+ readonly metadata?: DisplayMetadata;
50
+ };
51
+ type BuildDocxDisplayListResult = {
52
+ /** The pages every backend paints: the PDF writer, the DOM and HTML backends. */
53
+ readonly list: DisplayList;
54
+ /** Stories the headless pipeline does not paginate. */
55
+ readonly layoutGaps: readonly HeadlessLayoutGap[];
56
+ /** Faces measured with a stand-in. */
57
+ readonly measurementSubstitutions: readonly HeadlessFontSubstitution[];
58
+ };
59
+ /**
60
+ * Lay a package out headlessly and build its display list: the one structure
61
+ * the PDF writer and the DOM backend both paint. Runs one at a time with
62
+ * every other headless build and export, for the reason
63
+ * {@link runExclusively} gives.
64
+ */
65
+ declare const buildDocxDisplayList: (input: DocxInput, options: BuildDocxDisplayListOptions) => Promise<Result<BuildDocxDisplayListResult, ExportPdfError>>;
41
66
  declare const exportDocxToPdf: (input: DocxInput, options: ExportDocxToPdfOptions) => Promise<Result<ExportDocxToPdfResult, ExportPdfError>>;
67
+ type WriteDisplayListPdfOptions = Pick<ExportDocxToPdfOptions, "fonts" | "timestamp" | "producer">;
68
+ type WriteDisplayListPdfResult = Pick<ExportDocxToPdfResult, "bytes" | "pageCount" | "embeddingSubstitutions" | "unencodable">;
69
+ /**
70
+ * Write a display list built by {@link buildDocxDisplayList} (or narrowed by
71
+ * `selectDisplayPages`) as a PDF, embedding faces from the same source the
72
+ * layout measured with.
73
+ */
74
+ declare const writeDisplayListPdf: (list: DisplayList, options: WriteDisplayListPdfOptions) => Promise<Result<WriteDisplayListPdfResult, ExportPdfError>>;
42
75
  //#endregion
43
- export { ExportDocxToPdfOptions, ExportDocxToPdfResult, ExportPdfError, exportDocxToPdf };
76
+ export { BuildDocxDisplayListOptions, BuildDocxDisplayListResult, ExportDocxToPdfOptions, ExportDocxToPdfResult, ExportPdfError, WriteDisplayListPdfOptions, WriteDisplayListPdfResult, buildDocxDisplayList, exportDocxToPdf, writeDisplayListPdf };
@@ -1,5 +1,6 @@
1
1
  import { buildDisplayList } from "./display-list/build/buildDisplayList.js";
2
2
  import { displayCommentsFrom } from "./display-list/build/commentAnnotations.js";
3
+ import { selectDisplayPages } from "./display-list/selectDisplayPages.js";
3
4
  import { installHeadlessMeasureProvider } from "./fonts/headlessMeasure.js";
4
5
  import { layoutDocxHeadless } from "./headless-layout.js";
5
6
  import { getMeasureProvider, setMeasureProvider } from "./layout-engine/measure/measureProvider.js";
@@ -52,16 +53,59 @@ const runExclusively = (run) => {
52
53
  exportQueue = current.then(() => void 0, () => void 0);
53
54
  return current;
54
55
  };
55
- const exportDocxToPdf = (input, options) => runExclusively(() => exportOnce(input, options));
56
- const exportOnce = async (input, options) => {
56
+ /**
57
+ * Lay a package out headlessly and build its display list: the one structure
58
+ * the PDF writer and the DOM backend both paint. Runs one at a time with
59
+ * every other headless build and export, for the reason
60
+ * {@link runExclusively} gives.
61
+ */
62
+ const buildDocxDisplayList = (input, options) => runExclusively(async () => {
57
63
  const callerProvider = getMeasureProvider();
58
64
  try {
59
- return await exportWithHeadlessProvider(input, options);
65
+ return await buildWithHeadlessProvider(input, options);
60
66
  } finally {
61
67
  setMeasureProvider(callerProvider);
62
68
  }
69
+ });
70
+ const exportDocxToPdf = async (input, options) => {
71
+ const built = await buildDocxDisplayList(input, {
72
+ fonts: options.fonts,
73
+ ...options.metadata === void 0 ? {} : { metadata: options.metadata }
74
+ });
75
+ if (built.isErr()) return Result.err(built.error);
76
+ const list = options.pages === void 0 ? built.value.list : selectDisplayPages(built.value.list, options.pages);
77
+ const written = await writeDisplayListPdf(list, options);
78
+ if (written.isErr()) return Result.err(written.error);
79
+ return Result.ok({
80
+ ...written.value,
81
+ unsupported: list.unsupported,
82
+ layoutGaps: built.value.layoutGaps,
83
+ measurementSubstitutions: built.value.measurementSubstitutions
84
+ });
85
+ };
86
+ /**
87
+ * Write a display list built by {@link buildDocxDisplayList} (or narrowed by
88
+ * `selectDisplayPages`) as a PDF, embedding faces from the same source the
89
+ * layout measured with.
90
+ */
91
+ const writeDisplayListPdf = async (list, options) => {
92
+ const written = await writePdf(list, {
93
+ fonts: { load: (face) => options.fonts.load(toFontRequest(face)) },
94
+ timestamp: options.timestamp,
95
+ ...options.producer === void 0 ? {} : { producer: options.producer }
96
+ });
97
+ if (written.isErr()) return Result.err(new ExportPdfError({
98
+ message: written.error.message,
99
+ cause: written.error
100
+ }));
101
+ return Result.ok({
102
+ bytes: written.value.bytes,
103
+ pageCount: list.pages.length,
104
+ embeddingSubstitutions: written.value.substitutions,
105
+ unencodable: written.value.unencodable
106
+ });
63
107
  };
64
- const exportWithHeadlessProvider = async (input, options) => {
108
+ const buildWithHeadlessProvider = async (input, options) => {
65
109
  const headless = installHeadlessMeasureProvider(options.fonts);
66
110
  const laidOut = await layoutDocxHeadless(input, { pageGap: 0 });
67
111
  if (laidOut.isErr()) return Result.err(new ExportPdfError({
@@ -77,24 +121,11 @@ const exportWithHeadlessProvider = async (input, options) => {
77
121
  ...laidOut.value.document.package.document.comments === void 0 ? {} : { comments: displayCommentsFrom(laidOut.value.document.package.document.comments) },
78
122
  ...options.metadata === void 0 ? {} : { metadata: options.metadata }
79
123
  });
80
- const written = await writePdf(list, {
81
- fonts: { load: (face) => options.fonts.load(toFontRequest(face)) },
82
- timestamp: options.timestamp,
83
- ...options.producer === void 0 ? {} : { producer: options.producer }
84
- });
85
- if (written.isErr()) return Result.err(new ExportPdfError({
86
- message: written.error.message,
87
- cause: written.error
88
- }));
89
124
  return Result.ok({
90
- bytes: written.value.bytes,
91
- pageCount: list.pages.length,
92
- unsupported: list.unsupported,
125
+ list,
93
126
  layoutGaps: laidOut.value.unsupported,
94
- measurementSubstitutions: headless.substitutions(),
95
- embeddingSubstitutions: written.value.substitutions,
96
- unencodable: written.value.unencodable
127
+ measurementSubstitutions: headless.substitutions()
97
128
  });
98
129
  };
99
130
  //#endregion
100
- export { ExportPdfError, exportDocxToPdf };
131
+ export { ExportPdfError, buildDocxDisplayList, exportDocxToPdf, writeDisplayListPdf };
@@ -0,0 +1,47 @@
1
+ import { HeadlessFontSource } from "./headlessMeasure.js";
2
+ //#region src/fonts/fontsourceFaces.d.ts
3
+ /** Where `@fontsource` files come from: `null` when the package or file is absent. */
4
+ type FontsourceFiles = {
5
+ /** Bytes of `@fontsource/<packageName>/<relativePath>`. */
6
+ readonly read: (packageName: string, relativePath: string) => Uint8Array | null;
7
+ };
8
+ /** Each bundled family and the `@fontsource` package that ships it. */
9
+ declare const FONTSOURCE_PACKAGES: {
10
+ readonly Arimo: "arimo";
11
+ readonly Caladea: "caladea";
12
+ readonly Carlito: "carlito";
13
+ readonly Cousine: "cousine";
14
+ readonly Lato: "lato";
15
+ readonly "Noto Sans Arabic": "noto-sans-arabic";
16
+ readonly "Source Sans 3": "source-sans-3";
17
+ readonly Tinos: "tinos";
18
+ };
19
+ /**
20
+ * A CSS `<string>` token, quoted and escaped. A family name comes from an
21
+ * authored document and is emitted into a `<style>` element, so the
22
+ * backslash is escaped first, then the quote, and line terminators become hex
23
+ * escapes, which CSS strings require.
24
+ */
25
+ declare const cssString: (value: string) => string;
26
+ type FontsourceFontFaceCssOptions = {
27
+ /**
28
+ * Families to declare beyond the names the faces answer to directly. Pass a
29
+ * display list's own `fonts` families: a face reached through the fallback
30
+ * chain has no rule of its own, and the page names it by its authored name.
31
+ */
32
+ readonly families?: readonly string[];
33
+ };
34
+ type FontsourceFaces = {
35
+ /** Binaries for the headless measurer and the PDF writer. */
36
+ readonly source: HeadlessFontSource;
37
+ /**
38
+ * `@font-face` rules with the `.woff` bytes inlined as `data:` URLs, one
39
+ * rule per served subset of each face, so a browser paints each code point
40
+ * from the binary that was measured.
41
+ */
42
+ readonly fontFaceCss: (options?: FontsourceFontFaceCssOptions) => string;
43
+ };
44
+ /** The `@fontsource` faces, read through `files` and cached per face. */
45
+ declare const createFontsourceFaces: (files: FontsourceFiles) => FontsourceFaces;
46
+ //#endregion
47
+ export { FONTSOURCE_PACKAGES, FontsourceFaces, FontsourceFiles, FontsourceFontFaceCssOptions, createFontsourceFaces, cssString };