@stll/folio-core 0.48.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 (170) hide show
  1. package/dist/ai-edits/apply.js +565 -121
  2. package/dist/ai-edits/headless.d.ts +37 -1
  3. package/dist/ai-edits/headless.js +47 -16
  4. package/dist/ai-edits/minimal-replacement.d.ts +64 -0
  5. package/dist/ai-edits/minimal-replacement.js +282 -0
  6. package/dist/controller/layoutPipeline.js +11 -1
  7. package/dist/display-list/build/paragraphPrimitives.js +63 -7
  8. package/dist/display-list/build/textBoxPrimitives.js +1 -1
  9. package/dist/display-list/build/unsupported.d.ts +2 -2
  10. package/dist/display-list/build/unsupported.js +0 -0
  11. package/dist/display-list/dom/renderDisplayListToDom.d.ts +10 -1
  12. package/dist/display-list/dom/renderDisplayListToDom.js +8 -8
  13. package/dist/display-list/html/renderDisplayListToHtml.d.ts +21 -0
  14. package/dist/display-list/html/renderDisplayListToHtml.js +126 -0
  15. package/dist/display-list/selectDisplayPages.d.ts +6 -0
  16. package/dist/display-list/selectDisplayPages.js +44 -0
  17. package/dist/docx/blockContentParser.js +2 -2
  18. package/dist/docx/bulletMarkers.d.ts +18 -2
  19. package/dist/docx/bulletMarkers.js +27 -2
  20. package/dist/docx/drawingGroupChildren.d.ts +60 -0
  21. package/dist/docx/drawingGroupChildren.js +176 -0
  22. package/dist/docx/drawingUtils.js +3 -0
  23. package/dist/docx/groupDrawingParser.d.ts +7 -1
  24. package/dist/docx/groupDrawingParser.js +123 -32
  25. package/dist/docx/packageParts.d.ts +45 -1
  26. package/dist/docx/packageParts.js +93 -2
  27. package/dist/docx/paraIdAttribute.d.ts +3 -2
  28. package/dist/docx/paragraphParser.js +91 -7
  29. package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
  30. package/dist/docx/runParser.js +10 -0
  31. package/dist/docx/selectiveSaveFlags.d.ts +10 -3
  32. package/dist/docx/selectiveSaveFlags.js +1 -1
  33. package/dist/docx/selectiveXmlPatch.d.ts +56 -34
  34. package/dist/docx/selectiveXmlPatch.js +222 -115
  35. package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
  36. package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
  37. package/dist/docx/serializer/paragraphSerializer.js +5 -4
  38. package/dist/docx/serializer/runSerializer.d.ts +5 -1
  39. package/dist/docx/serializer/runSerializer.js +4 -3
  40. package/dist/docx/shapeAlternateContent.d.ts +25 -0
  41. package/dist/docx/shapeAlternateContent.js +52 -0
  42. package/dist/docx/textBoxParser.d.ts +10 -2
  43. package/dist/docx/textBoxParser.js +9 -3
  44. package/dist/export-pdf.d.ts +35 -2
  45. package/dist/export-pdf.js +51 -20
  46. package/dist/fonts/fontsourceFaces.d.ts +47 -0
  47. package/dist/fonts/fontsourceFaces.js +230 -0
  48. package/dist/internal/headlessRevisionResolution.js +50 -13
  49. package/dist/layout-bridge/convert/flowBlockPostprocessing.d.ts +8 -0
  50. package/dist/layout-bridge/convert/flowBlockPostprocessing.js +106 -0
  51. package/dist/layout-bridge/convert/flowBorders.d.ts +17 -0
  52. package/dist/layout-bridge/convert/flowBorders.js +31 -0
  53. package/dist/layout-bridge/convert/flowConversionShared.d.ts +111 -0
  54. package/dist/layout-bridge/convert/flowConversionShared.js +23 -0
  55. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -1
  56. package/dist/layout-bridge/convert/footnoteLayout.js +2 -1
  57. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +2 -1
  58. package/dist/layout-bridge/convert/imageConversion.d.ts +33 -0
  59. package/dist/layout-bridge/convert/imageConversion.js +118 -0
  60. package/dist/layout-bridge/convert/listMarkers.d.ts +28 -0
  61. package/dist/layout-bridge/convert/listMarkers.js +98 -0
  62. package/dist/layout-bridge/convert/noteReferences.d.ts +14 -0
  63. package/dist/layout-bridge/convert/noteReferences.js +29 -0
  64. package/dist/layout-bridge/convert/pageBreakSplitting.d.ts +16 -0
  65. package/dist/layout-bridge/convert/pageBreakSplitting.js +140 -0
  66. package/dist/layout-bridge/convert/paragraphAttrs.d.ts +20 -0
  67. package/dist/layout-bridge/convert/paragraphAttrs.js +234 -0
  68. package/dist/layout-bridge/convert/paragraphConversion.d.ts +12 -0
  69. package/dist/layout-bridge/convert/paragraphConversion.js +112 -0
  70. package/dist/layout-bridge/convert/paragraphRuns.d.ts +15 -0
  71. package/dist/layout-bridge/convert/paragraphRuns.js +281 -0
  72. package/dist/layout-bridge/convert/runFormattingMerge.d.ts +19 -0
  73. package/dist/layout-bridge/convert/runFormattingMerge.js +167 -0
  74. package/dist/layout-bridge/convert/runMarkFormatting.d.ts +11 -0
  75. package/dist/layout-bridge/convert/runMarkFormatting.js +291 -0
  76. package/dist/layout-bridge/convert/sectionBoundaries.d.ts +35 -0
  77. package/dist/layout-bridge/convert/sectionBoundaries.js +114 -0
  78. package/dist/layout-bridge/convert/tableConversion.d.ts +12 -0
  79. package/dist/layout-bridge/convert/tableConversion.js +303 -0
  80. package/dist/layout-bridge/convert/textBoxFill.d.ts +20 -0
  81. package/dist/layout-bridge/convert/textBoxFill.js +42 -0
  82. package/dist/layout-bridge/convert/textFormattingConversion.d.ts +22 -0
  83. package/dist/layout-bridge/convert/textFormattingConversion.js +120 -0
  84. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +5 -113
  85. package/dist/layout-bridge/convert/toFlowBlocks.js +11 -2060
  86. package/dist/layout-bridge/engine/hitTest.js +1 -1
  87. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  88. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  89. package/dist/layout-bridge/engine/selectionRects.js +1 -1
  90. package/dist/layout-engine/imageLayout.d.ts +9 -0
  91. package/dist/layout-engine/imageLayout.js +48 -0
  92. package/dist/layout-engine/index.d.ts +4 -19
  93. package/dist/layout-engine/index.js +14 -910
  94. package/dist/layout-engine/keep-together.d.ts +5 -2
  95. package/dist/layout-engine/keep-together.js +5 -1
  96. package/dist/layout-engine/layoutFlowShared.d.ts +6 -0
  97. package/dist/layout-engine/layoutFlowShared.js +8 -0
  98. package/dist/layout-engine/measure/advanceComposition.js +49 -3
  99. package/dist/layout-engine/measure/crossRunWords.d.ts +45 -0
  100. package/dist/layout-engine/measure/crossRunWords.js +172 -0
  101. package/dist/layout-engine/measure/floatingLineClearance.d.ts +24 -0
  102. package/dist/layout-engine/measure/floatingLineClearance.js +44 -0
  103. package/dist/layout-engine/measure/inlineObjectMetrics.d.ts +6 -0
  104. package/dist/layout-engine/measure/inlineObjectMetrics.js +52 -0
  105. package/dist/layout-engine/measure/justification.d.ts +32 -0
  106. package/dist/layout-engine/measure/justification.js +83 -0
  107. package/dist/layout-engine/measure/lineFitting.d.ts +48 -0
  108. package/dist/layout-engine/measure/lineFitting.js +145 -0
  109. package/dist/layout-engine/measure/lineTypography.d.ts +44 -0
  110. package/dist/layout-engine/measure/lineTypography.js +124 -0
  111. package/dist/layout-engine/measure/measureBlocks.js +7 -4
  112. package/dist/layout-engine/measure/measureContainer.js +46 -3
  113. package/dist/layout-engine/measure/measureHelpers.js +13 -3
  114. package/dist/layout-engine/measure/measureParagraph.d.ts +1 -20
  115. package/dist/layout-engine/measure/measureParagraph.js +41 -780
  116. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +109 -0
  117. package/dist/layout-engine/measure/paragraphMeasureShared.js +82 -0
  118. package/dist/layout-engine/measure/paragraphTabs.d.ts +18 -0
  119. package/dist/layout-engine/measure/paragraphTabs.js +78 -0
  120. package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
  121. package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
  122. package/dist/layout-engine/paginator.d.ts +2 -0
  123. package/dist/layout-engine/paginator.js +5 -1
  124. package/dist/layout-engine/paragraphLayout.d.ts +23 -0
  125. package/dist/layout-engine/paragraphLayout.js +204 -0
  126. package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
  127. package/dist/layout-engine/paragraphSpacing.js +2 -1
  128. package/dist/layout-engine/sectionLayout.d.ts +45 -0
  129. package/dist/layout-engine/sectionLayout.js +216 -0
  130. package/dist/layout-engine/tableLayout.d.ts +17 -0
  131. package/dist/layout-engine/tableLayout.js +369 -0
  132. package/dist/layout-engine/tableRowBreak.d.ts +8 -1
  133. package/dist/layout-engine/tableRowBreak.js +22 -1
  134. package/dist/layout-engine/textBoxLayout.d.ts +15 -0
  135. package/dist/layout-engine/textBoxLayout.js +137 -0
  136. package/dist/layout-engine/types.d.ts +21 -2
  137. package/dist/layout-painter/renderPage.js +1 -0
  138. package/dist/layout-painter/renderParagraph.js +41 -4
  139. package/dist/layout-painter/renderTextBox.d.ts +12 -2
  140. package/dist/layout-painter/renderTextBox.js +26 -2
  141. package/dist/paged-layout/sectionBlockWidths.d.ts +2 -1
  142. package/dist/paged-layout/sectionBlockWidths.js +1 -1
  143. package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
  144. package/dist/prosemirror/alternateContentAttrs.js +35 -0
  145. package/dist/prosemirror/attrs/index.js +90 -0
  146. package/dist/prosemirror/commands/comments.js +51 -41
  147. package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
  148. package/dist/prosemirror/contentControlRevisions.js +84 -0
  149. package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
  150. package/dist/prosemirror/conversion/toProseDoc.js +42 -19
  151. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
  152. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
  153. package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
  154. package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
  155. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
  156. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
  157. package/dist/prosemirror/listMarker.js +2 -2
  158. package/dist/prosemirror/paragraphDirection.d.ts +29 -2
  159. package/dist/prosemirror/paragraphDirection.js +21 -2
  160. package/dist/prosemirror/plugins/suggestionMode.js +37 -8
  161. package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
  162. package/dist/prosemirror/rejoinRunCarriers.js +66 -0
  163. package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
  164. package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
  165. package/dist/prosemirror/schema/nodes.d.ts +37 -5
  166. package/dist/server.d.ts +3 -2
  167. package/dist/types/content.d.ts +2 -2
  168. package/package.json +2 -2
  169. package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
  170. package/dist/layout-engine/justifiedLineFit.js +0 -6
@@ -7,8 +7,6 @@ import { DisplayUnsupported } from "../types.js";
7
7
  declare const UNSUPPORTED_CONSTRUCT: {
8
8
  /** `w:effect` animations (blinkBackground, shimmer, …). */
9
9
  readonly textEffect: "textEffect";
10
- /** `w:smallCaps`: advances are correct, the small-cap glyph choice is not expressible. */
11
- readonly smallCaps: "smallCaps";
12
10
  /** `w:w` horizontal scale: folded into advances, but the glyphs are not narrowed. */
13
11
  readonly horizontalScale: "horizontalScale";
14
12
  /** `w:em` emphasis marks. */
@@ -39,6 +37,8 @@ declare const UNSUPPORTED_CONSTRUCT: {
39
37
  readonly headerFooterContent: "headerFooterContent";
40
38
  /** `a:bodyPr` middle/bottom anchoring inside a text box. */
41
39
  readonly textBoxVerticalAlign: "textBoxVerticalAlign";
40
+ /** A text box's `a:gradFill`: the primitives carry no gradient. */
41
+ readonly textBoxGradientFill: "textBoxGradientFill";
42
42
  /** A fragment kind with no builder. */
43
43
  readonly fragmentKind: "fragmentKind";
44
44
  /** A fragment whose block or measure is missing from the block lookup. */
@@ -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
  }
@@ -297,7 +297,6 @@ const paintGlyphRun = (run, context) => {
297
297
  if (run.unicodeBidi !== void 0) span.style.unicodeBidi = run.unicodeBidi;
298
298
  if (run.stroke !== void 0) span.style.webkitTextStroke = `${px(run.stroke.thicknessPx)} ${cssColor(run.stroke.color)}`;
299
299
  span.style.fontKerning = run.kerning ? "normal" : "none";
300
- if (run.smallCaps) span.style.fontVariant = "small-caps";
301
300
  applyAdjustments(span, run.adjustments);
302
301
  span.dataset["advanceSum"] = String(advanceSum);
303
302
  applyModelRange(span, run.pmRange);
@@ -400,7 +399,7 @@ const paintImage = ({ image, rect, crop, opacity, brightness, contrast }, contex
400
399
  element.style.height = px(fullHeight);
401
400
  }
402
401
  element.alt = "";
403
- element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format]);
402
+ element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format], context.binaryUrls);
404
403
  clip.append(element);
405
404
  context.parent.append(clip);
406
405
  };
@@ -498,6 +497,7 @@ const renderPage = (page, options) => {
498
497
  }
499
498
  const context = {
500
499
  doc: options.doc,
500
+ binaryUrls: options.binaryUrls ?? "objectUrl",
501
501
  fonts: options.fonts,
502
502
  images: options.images,
503
503
  parent: element,
@@ -596,11 +596,11 @@ const paintRegionTree = ({ primitives, regions }, context) => {
596
596
  };
597
597
  const renderDisplayPageToDom = (page, options) => renderPage(page, {
598
598
  ...options,
599
- fontFaceCss: embeddedFontFaceCss(options.fonts)
599
+ fontFaceCss: embeddedFontFaceCss(options.fonts, options.binaryUrls ?? "objectUrl")
600
600
  });
601
601
  /** One `div.layout-page` per display page, in page order. */
602
602
  const renderDisplayListToDom = (list, options) => {
603
- const fontFaceCss = embeddedFontFaceCss(list.fonts);
603
+ const fontFaceCss = embeddedFontFaceCss(list.fonts, options.binaryUrls ?? "objectUrl");
604
604
  return list.pages.map((page, pageIndex) => renderPage(page, {
605
605
  ...options,
606
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 { parseBookmarkEnd, parseBookmarkStart } from "./bookmarkParser.js";
2
- import { convertBulletToUnicode } from "./bulletMarkers.js";
2
+ import { bulletMarkerFontName, convertBulletToUnicode } from "./bulletMarkers.js";
3
3
  import { CAPTURE, dispatchChildrenWithContext, ownedElsewhere, withPreservedChildren } from "./containerChildren.js";
4
4
  import { isNumberingReference } from "./numberingReference.js";
5
5
  import { formatOoxmlCounter } from "./ooxmlCounterFormatter.js";
@@ -62,7 +62,7 @@ const computeListMarker = (paragraph, { numbering, listCounters, abstractCounter
62
62
  previousList.numId = numId;
63
63
  const pattern = listRendering.marker;
64
64
  if (listRendering.isBullet) {
65
- listRendering.marker = convertBulletToUnicode(pattern || "");
65
+ listRendering.marker = convertBulletToUnicode(pattern || "", bulletMarkerFontName(listRendering.markerFormatting));
66
66
  previousList.abstractNumId = null;
67
67
  previousList.fromStyle = false;
68
68
  previousList.numId = null;
@@ -1,4 +1,20 @@
1
1
  //#region src/docx/bulletMarkers.d.ts
2
- declare const convertBulletToUnicode: (bulletChar: string) => string;
2
+ type MarkerFontFamily = {
3
+ ascii?: string | undefined;
4
+ hAnsi?: string | undefined;
5
+ };
6
+ /** The face a bullet level's `w:rFonts` names for its (Latin-range) marker character. */
7
+ declare const bulletMarkerFontName: (formatting: {
8
+ fontFamily?: MarkerFontFamily | undefined;
9
+ } | null | undefined) => string | undefined;
10
+ /**
11
+ * Map a bullet level's `w:lvlText` to the character painted for it.
12
+ *
13
+ * `fontName` is the level's own `w:rFonts` face. When it is an ordinary text
14
+ * font, a Latin-range character is the letter itself (the common `o` bullet
15
+ * in a monospace face); without a named face the character is read as a
16
+ * symbol-font code.
17
+ */
18
+ declare const convertBulletToUnicode: (bulletChar: string, fontName?: string) => string;
3
19
  //#endregion
4
- export { convertBulletToUnicode };
20
+ export { bulletMarkerFontName, convertBulletToUnicode };
@@ -28,10 +28,35 @@ const SYMBOL_BULLET_MAP = {
28
28
  62: ">",
29
29
  45: "-"
30
30
  };
31
- const convertBulletToUnicode = (bulletChar) => {
31
+ /**
32
+ * Fonts whose single-byte codes name pictographs rather than Latin letters.
33
+ * A `w:lvlText` character below U+0100 only means a symbol glyph when the
34
+ * numbering level's `w:rFonts` selects one of these faces.
35
+ */
36
+ const SYMBOL_ENCODED_FONTS = /* @__PURE__ */ new Set([
37
+ "symbol",
38
+ "wingdings",
39
+ "wingdings 2",
40
+ "wingdings 3",
41
+ "webdings"
42
+ ]);
43
+ /** The face a bullet level's `w:rFonts` names for its (Latin-range) marker character. */
44
+ const bulletMarkerFontName = (formatting) => formatting?.fontFamily?.ascii ?? formatting?.fontFamily?.hAnsi;
45
+ /** A printable Latin-1 character in a named, non-symbol face paints as itself. */
46
+ const isTextFontCharacter = (charCode, fontName) => fontName !== void 0 && (charCode >= 32 && charCode < 127 || charCode >= 160 && charCode < 256) && !SYMBOL_ENCODED_FONTS.has(fontName.trim().toLowerCase());
47
+ /**
48
+ * Map a bullet level's `w:lvlText` to the character painted for it.
49
+ *
50
+ * `fontName` is the level's own `w:rFonts` face. When it is an ordinary text
51
+ * font, a Latin-range character is the letter itself (the common `o` bullet
52
+ * in a monospace face); without a named face the character is read as a
53
+ * symbol-font code.
54
+ */
55
+ const convertBulletToUnicode = (bulletChar, fontName) => {
32
56
  if (!bulletChar || bulletChar.trim() === "") return "•";
33
57
  const charCode = bulletChar.codePointAt(0);
34
58
  if (charCode === void 0) return "•";
59
+ if (isTextFontCharacter(charCode, fontName)) return bulletChar;
35
60
  const mapped = SYMBOL_BULLET_MAP[charCode];
36
61
  if (mapped !== void 0) return mapped;
37
62
  if (charCode >= 57344 && charCode <= 63743) return "•";
@@ -39,4 +64,4 @@ const convertBulletToUnicode = (bulletChar) => {
39
64
  return bulletChar;
40
65
  };
41
66
  //#endregion
42
- export { convertBulletToUnicode };
67
+ export { bulletMarkerFontName, convertBulletToUnicode };
@@ -0,0 +1,60 @@
1
+ import { XmlElement } from "./xmlParser.js";
2
+ //#region src/docx/drawingGroupChildren.d.ts
3
+ type GroupTextBoxFrame = {
4
+ /** Element indices from the `wpg:wgp` down to the `wps:wsp`. */
5
+ path: number[];
6
+ wsp: XmlElement;
7
+ /** EMUs from the group's top-left corner. */
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ /** Degrees clockwise. */
13
+ rotation: number;
14
+ flipH: boolean;
15
+ flipV: boolean;
16
+ };
17
+ type Transform2D = {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ childX: number;
23
+ childY: number;
24
+ childWidth: number;
25
+ childHeight: number;
26
+ rotation: number;
27
+ flipH: boolean;
28
+ flipV: boolean;
29
+ };
30
+ /** A `CT_GroupTransform2D` or `CT_Transform2D`; absent children read as zero. */
31
+ declare const readTransform: (xfrm: XmlElement | null) => Transform2D;
32
+ /**
33
+ * Every `wps:wsp` with a text box in the group, nested groups included, with
34
+ * its frame in EMUs from the group's top-left corner.
35
+ *
36
+ * A nested group that is rotated or flipped turns its children's frames into
37
+ * something other than a rectangle in the drawing's axes, so its text boxes
38
+ * are left out and stay with the preview.
39
+ */
40
+ declare const collectGroupTextBoxes: (group: XmlElement, width: number, height: number) => GroupTextBoxFrame[];
41
+ /** Identity of a group drawing's authored XML. */
42
+ declare const groupXmlFingerprint: (rawXml: string) => string;
43
+ /** Identity of a text box's content, compared on save to tell an edit. */
44
+ declare const groupTextContentFingerprint: (content: unknown) => string;
45
+ type GroupTextBoxEdit = {
46
+ path: readonly number[];
47
+ /** Serialized `w:txbxContent` children. */
48
+ contentXml: string;
49
+ };
50
+ /**
51
+ * The captured group XML with each edited text box's content replaced, or
52
+ * undefined when an edit names no text box in it.
53
+ *
54
+ * The replacement is written as a placeholder element and substituted after
55
+ * the tree is serialized, so the new content is never reparsed and the rest
56
+ * of the capture is written exactly as it was read.
57
+ */
58
+ declare const replaceGroupTextBoxContent: (rawXml: string, edits: readonly GroupTextBoxEdit[]) => string | undefined;
59
+ //#endregion
60
+ export { GroupTextBoxEdit, GroupTextBoxFrame, collectGroupTextBoxes, groupTextContentFingerprint, groupXmlFingerprint, readTransform, replaceGroupTextBoxContent };
@@ -0,0 +1,176 @@
1
+ import { canonicalJson } from "../utils/canonicalJson.js";
2
+ import { captureVerbatimXml } from "./verbatimCapture.js";
3
+ import { findChildByLocalName, findDeep, getAttribute, getChildElements, getLocalName, parseNumericAttribute, parseXml } from "./xmlParser.js";
4
+ //#region src/docx/drawingGroupChildren.ts
5
+ /**
6
+ * The text boxes of a DrawingML group (`wpg:wgp`), read as frames folio can lay
7
+ * out, and written back into the group's authored XML.
8
+ *
9
+ * A group states a child coordinate space (`a:chOff` / `a:chExt`) that its
10
+ * own frame (`a:off` / `a:ext`) maps onto, and every child's `a:xfrm` is in
11
+ * that space; a nested `wpg:grpSp` repeats the mapping one level down
12
+ * (ECMA-376 §20.1.7.5). Composing the mappings gives each `wps:wsp` a frame
13
+ * in EMUs relative to the group's own top-left corner. Only the frame is
14
+ * mapped: the text inside keeps its authored size and the body insets stay in
15
+ * EMUs.
16
+ */
17
+ /** `a:xfrm@rot` is in 60,000ths of a degree. */
18
+ const ROTATION_UNITS_PER_DEGREE = 6e4;
19
+ /** Deeper nesting than this is not a drawing anyone authored. */
20
+ const MAX_GROUP_DEPTH = 16;
21
+ const MAX_GROUP_TEXT_BOXES = 256;
22
+ const numberAttribute = (element, name) => parseNumericAttribute(element, null, name) ?? 0;
23
+ const onOffAttribute = (element, name) => {
24
+ const value = getAttribute(element, null, name);
25
+ return value === "1" || value === "true" || value === "on";
26
+ };
27
+ /** A `CT_GroupTransform2D` or `CT_Transform2D`; absent children read as zero. */
28
+ const readTransform = (xfrm) => {
29
+ const offset = findChildByLocalName(xfrm, "off");
30
+ const extent = findChildByLocalName(xfrm, "ext");
31
+ const childOffset = findChildByLocalName(xfrm, "chOff");
32
+ const childExtent = findChildByLocalName(xfrm, "chExt");
33
+ return {
34
+ x: numberAttribute(offset, "x"),
35
+ y: numberAttribute(offset, "y"),
36
+ width: numberAttribute(extent, "cx"),
37
+ height: numberAttribute(extent, "cy"),
38
+ childX: numberAttribute(childOffset, "x"),
39
+ childY: numberAttribute(childOffset, "y"),
40
+ childWidth: numberAttribute(childExtent, "cx"),
41
+ childHeight: numberAttribute(childExtent, "cy"),
42
+ rotation: numberAttribute(xfrm, "rot") / ROTATION_UNITS_PER_DEGREE,
43
+ flipH: onOffAttribute(xfrm, "flipH"),
44
+ flipV: onOffAttribute(xfrm, "flipV")
45
+ };
46
+ };
47
+ const groupTransform = (group) => readTransform(findChildByLocalName(findChildByLocalName(group, "grpSpPr"), "xfrm"));
48
+ const shapeTransform = (shape) => readTransform(findChildByLocalName(findChildByLocalName(shape, "spPr"), "xfrm"));
49
+ /**
50
+ * The top-level mapping: the group's child space stretched over the drawing's
51
+ * extent. A missing or empty child extent means the child space is the
52
+ * drawing's own.
53
+ */
54
+ const rootMap = (group, width, height) => {
55
+ const transform = groupTransform(group);
56
+ const scaleX = transform.childWidth > 0 ? width / transform.childWidth : 1;
57
+ const scaleY = transform.childHeight > 0 ? height / transform.childHeight : 1;
58
+ return {
59
+ scaleX,
60
+ scaleY,
61
+ offsetX: -transform.childX * scaleX,
62
+ offsetY: -transform.childY * scaleY
63
+ };
64
+ };
65
+ /** A nested group's child space, mapped through its frame in the parent's space. */
66
+ const nestedMap = (parent, transform) => {
67
+ const scaleX = transform.childWidth > 0 ? transform.width / transform.childWidth : 1;
68
+ const scaleY = transform.childHeight > 0 ? transform.height / transform.childHeight : 1;
69
+ return {
70
+ scaleX: parent.scaleX * scaleX,
71
+ scaleY: parent.scaleY * scaleY,
72
+ offsetX: parent.scaleX * (transform.x - transform.childX * scaleX) + parent.offsetX,
73
+ offsetY: parent.scaleY * (transform.y - transform.childY * scaleY) + parent.offsetY
74
+ };
75
+ };
76
+ const hasTextBox = (wsp) => findChildByLocalName(findChildByLocalName(wsp, "txbx"), "txbxContent") !== null;
77
+ /**
78
+ * Every `wps:wsp` with a text box in the group, nested groups included, with
79
+ * its frame in EMUs from the group's top-left corner.
80
+ *
81
+ * A nested group that is rotated or flipped turns its children's frames into
82
+ * something other than a rectangle in the drawing's axes, so its text boxes
83
+ * are left out and stay with the preview.
84
+ */
85
+ const collectGroupTextBoxes = (group, width, height) => {
86
+ const frames = [];
87
+ const visit = (container, map, path, depth) => {
88
+ for (const [index, child] of getChildElements(container).entries()) {
89
+ if (frames.length >= MAX_GROUP_TEXT_BOXES) return;
90
+ const localName = getLocalName(child.name ?? "");
91
+ if (localName === "grpSp" && depth < MAX_GROUP_DEPTH) {
92
+ const transform = groupTransform(child);
93
+ if (transform.rotation === 0 && !transform.flipH && !transform.flipV) visit(child, nestedMap(map, transform), [...path, index], depth + 1);
94
+ continue;
95
+ }
96
+ if (localName !== "wsp" || !hasTextBox(child)) continue;
97
+ const transform = shapeTransform(child);
98
+ const frameWidth = transform.width * map.scaleX;
99
+ const frameHeight = transform.height * map.scaleY;
100
+ if (!(frameWidth > 0 && frameHeight > 0)) continue;
101
+ frames.push({
102
+ path: [...path, index],
103
+ wsp: child,
104
+ x: transform.x * map.scaleX + map.offsetX,
105
+ y: transform.y * map.scaleY + map.offsetY,
106
+ width: frameWidth,
107
+ height: frameHeight,
108
+ rotation: transform.rotation,
109
+ flipH: transform.flipH,
110
+ flipV: transform.flipV
111
+ });
112
+ }
113
+ };
114
+ visit(group, rootMap(group, width, height), [], 0);
115
+ return frames;
116
+ };
117
+ /** Two FNV-1a passes with different offsets: a short, stable identity. */
118
+ const fingerprint = (text) => {
119
+ let first = 2166136261;
120
+ let second = 16777619;
121
+ for (let index = 0; index < text.length; index += 1) {
122
+ const code = text.charCodeAt(index);
123
+ first = Math.imul(first ^ code, 16777619);
124
+ second = Math.imul(second ^ code, 96280213);
125
+ }
126
+ return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0).toString(16).padStart(8, "0")}`;
127
+ };
128
+ /** Identity of a group drawing's authored XML. */
129
+ const groupXmlFingerprint = (rawXml) => fingerprint(rawXml);
130
+ /** Identity of a text box's content, compared on save to tell an edit. */
131
+ const groupTextContentFingerprint = (content) => fingerprint(canonicalJson(content));
132
+ /** The group a captured drawing carries; a Fallback branch holds none. */
133
+ const findGroup = (root) => findDeep(root, "wpg", "wgp") ?? void 0;
134
+ const childAtPath = (group, path) => {
135
+ let current = group;
136
+ for (const index of path) current = current ? getChildElements(current)[index] : void 0;
137
+ return current;
138
+ };
139
+ /**
140
+ * The text box content element a path names, or undefined when the path does
141
+ * not lead to a `wps:wsp` holding one.
142
+ */
143
+ const textBoxContentAt = (group, path) => {
144
+ const wsp = childAtPath(group, path);
145
+ if (!wsp || getLocalName(wsp.name ?? "") !== "wsp") return;
146
+ return findChildByLocalName(findChildByLocalName(wsp, "txbx"), "txbxContent") ?? void 0;
147
+ };
148
+ /**
149
+ * The captured group XML with each edited text box's content replaced, or
150
+ * undefined when an edit names no text box in it.
151
+ *
152
+ * The replacement is written as a placeholder element and substituted after
153
+ * the tree is serialized, so the new content is never reparsed and the rest
154
+ * of the capture is written exactly as it was read.
155
+ */
156
+ const replaceGroupTextBoxContent = (rawXml, edits) => {
157
+ const root = parseXml(rawXml);
158
+ const group = findGroup(root);
159
+ if (!group) return;
160
+ const replacements = /* @__PURE__ */ new Map();
161
+ for (const [index, edit] of edits.entries()) {
162
+ const content = textBoxContentAt(group, edit.path);
163
+ if (!content) return;
164
+ const placeholder = `folio-group-text-${index}-${fingerprint(rawXml)}`;
165
+ content.elements = [{
166
+ type: "element",
167
+ name: placeholder
168
+ }];
169
+ replacements.set(`<${placeholder}/>`, edit.contentXml || "<w:p/>");
170
+ }
171
+ let xml = (root.elements ?? []).map((element) => captureVerbatimXml(element)).join("");
172
+ for (const [placeholder, contentXml] of replacements) xml = xml.replace(placeholder, () => contentXml);
173
+ return xml;
174
+ };
175
+ //#endregion
176
+ export { collectGroupTextBoxes, groupTextContentFingerprint, groupXmlFingerprint, readTransform, replaceGroupTextBoxContent };
@@ -107,6 +107,7 @@ function parseFill(spPr) {
107
107
  function parseGradientFill(gradientFill) {
108
108
  let type = "linear";
109
109
  let angle;
110
+ let scaled;
110
111
  const linear = findChildByLocalName(gradientFill, "lin");
111
112
  if (linear) {
112
113
  const authoredAngle = getAttribute(linear, null, "ang");
@@ -114,6 +115,7 @@ function parseGradientFill(gradientFill) {
114
115
  const parsedAngle = Number.parseInt(authoredAngle, 10);
115
116
  angle = Number.isNaN(parsedAngle) ? void 0 : parsedAngle / 6e4;
116
117
  }
118
+ scaled = parseOnOffValue(getAttribute(linear, null, "scaled"), void 0, "a:lin@scaled");
117
119
  }
118
120
  const path = findChildByLocalName(gradientFill, "path");
119
121
  if (path) {
@@ -139,6 +141,7 @@ function parseGradientFill(gradientFill) {
139
141
  gradient: {
140
142
  type,
141
143
  ...angle !== void 0 ? { angle } : {},
144
+ ...scaled !== void 0 ? { scaled } : {},
142
145
  stops
143
146
  }
144
147
  };