@stll/folio-core 0.6.0 → 0.7.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 (40) hide show
  1. package/dist/controller/layoutPipeline.js +33 -14
  2. package/dist/docx/blockContentParser.js +2 -100
  3. package/dist/docx/groupDrawingParser.d.ts +1 -1
  4. package/dist/docx/groupDrawingParser.js +49 -8
  5. package/dist/docx/paragraphTextBoxEnrichment.d.ts +9 -0
  6. package/dist/docx/paragraphTextBoxEnrichment.js +104 -0
  7. package/dist/docx/runParser.js +11 -2
  8. package/dist/docx/server/boundedArchive.d.ts +24 -0
  9. package/dist/docx/server/boundedArchive.js +106 -0
  10. package/dist/docx/server/extractDocxText.d.ts +23 -0
  11. package/dist/docx/server/extractDocxText.js +154 -0
  12. package/dist/docx/tableParser.js +2 -0
  13. package/dist/layout-bridge/convert/toFlowBlocks.js +75 -19
  14. package/dist/layout-bridge/sectionColumns.js +6 -1
  15. package/dist/layout-engine/index.js +120 -19
  16. package/dist/layout-engine/keep-together.d.ts +7 -5
  17. package/dist/layout-engine/keep-together.js +20 -4
  18. package/dist/layout-engine/measure/cache.js +2 -0
  19. package/dist/layout-engine/measure/measureBlocks.js +3 -2
  20. package/dist/layout-engine/measure/measureParagraph.js +30 -12
  21. package/dist/layout-engine/paginator.d.ts +2 -0
  22. package/dist/layout-engine/paginator.js +27 -15
  23. package/dist/layout-engine/tableRowBreak.js +3 -0
  24. package/dist/layout-engine/types.d.ts +20 -5
  25. package/dist/layout-painter/index.js +1 -1
  26. package/dist/layout-painter/renderPage.js +7 -2
  27. package/dist/layout-painter/renderParagraph.js +93 -9
  28. package/dist/layout-painter/renderTable.js +88 -10
  29. package/dist/paged-layout/sectionBlockWidths.js +11 -3
  30. package/dist/prosemirror/conversion/fromProseDoc.js +11 -2
  31. package/dist/prosemirror/conversion/toProseDoc.js +28 -20
  32. package/dist/prosemirror/extensions/nodes/TableExtension.js +3 -2
  33. package/dist/prosemirror/schema/nodes.d.ts +2 -1
  34. package/dist/prosemirror/utils/tabCalculator.js +1 -1
  35. package/dist/server.d.ts +3 -1
  36. package/dist/server.js +3 -1
  37. package/dist/utils/formatToStyle.js +3 -3
  38. package/dist/utils/units.d.ts +6 -6
  39. package/dist/utils/units.js +8 -8
  40. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
1
  import "../layout-engine/types.js";
2
2
  import { renderPages } from "../layout-painter/renderPage.js";
3
3
  import { templatePreviewValuesKey } from "../prosemirror/plugins/templatePreviewValues.js";
4
+ import { getMargins, getPageSize, twipsToPixels } from "../paged-layout/sectionGeometry.js";
5
+ import { getColumns } from "../layout-bridge/sectionColumns.js";
4
6
  import { toFlowBlocks } from "../layout-bridge/convert/toFlowBlocks.js";
5
7
  import { recordLayoutComplete, recordLayoutError, recordLayoutPhase, recordLayoutStart } from "../layout-engine/layoutInstrumentation.js";
6
8
  import { buildBookmarkPageMap } from "../fields/bookmarkPages.js";
@@ -15,9 +17,17 @@ import { layoutDocument } from "../layout-engine/index.js";
15
17
  import { measureBlocks, measureSingleBlockWithoutFloatingZones } from "../layout-engine/measure/measureBlocks.js";
16
18
  import { tryBuildIncrementalMeasures } from "../paged-layout/incrementalMeasure.js";
17
19
  import { computePerBlockMeasureInputs } from "../paged-layout/sectionBlockWidths.js";
18
- import { getMargins, getPageSize, twipsToPixels } from "../paged-layout/sectionGeometry.js";
19
20
  import { getDocumentWatermark } from "../watermark/index.js";
20
21
  //#region src/controller/layoutPipeline.ts
22
+ function bodyMarginsBelowHeader(authoredMargins, preparedHeader) {
23
+ if (!preparedHeader) return authoredMargins;
24
+ const headerBottom = (authoredMargins.header ?? 0) + (preparedHeader.marginPushBottom ?? preparedHeader.height);
25
+ if (headerBottom <= authoredMargins.top) return authoredMargins;
26
+ return {
27
+ ...authoredMargins,
28
+ top: headerBottom
29
+ };
30
+ }
21
31
  function runLayoutPipeline(deps, state, options = {}) {
22
32
  const { contentWidth, columns, pageSize, margins, pageGap, syncCoordinator, headerContent, footerContent, firstPageHeaderContent, firstPageFooterContent, headerContentRId, footerContentRId, firstPageHeaderContentRId, firstPageFooterContentRId, sectionHeaderFooterRefs, theme: _theme, sectionProperties, document, defaultTabStop, mirrorMargins, styles, layout, hfPMs, painter, pagesContainer, session, renderHfFromContentOrPm, renderHeaderFooterContentByRId, documentFontsAreLoaded, buildFootnoteRenderItems, describeInvalidHighlightMarks, emptyTemplatePreviewEntries: EMPTY_TEMPLATE_PREVIEW_ENTRIES } = deps;
23
33
  let outcome = {};
@@ -91,17 +101,29 @@ function runLayoutPipeline(deps, state, options = {}) {
91
101
  let firstPageFooterForRender = hasTitlePg ? renderHfFromContentOrPm(firstPageFooterContent, firstPageFooterContentRId, hfPMs, contentWidth, hfMetricsFooter, hfOptions) : void 0;
92
102
  let headerContentByRId = renderHeaderFooterContentByRId(document?.package.headers, hfPMs, contentWidth, hfMetricsHeader, hfOptions);
93
103
  let footerContentByRId = renderHeaderFooterContentByRId(document?.package.footers, hfPMs, contentWidth, hfMetricsFooter, hfOptions);
104
+ const initialBodyMargins = bodyMarginsBelowHeader(margins, headerContentForRender);
94
105
  recordPhaseDuration("header-footer", phaseStartedAt);
95
106
  phaseStartedAt = performance.now();
96
107
  const bodyLayoutConfig = {
97
108
  pageSize,
98
- margins
109
+ margins: initialBodyMargins
99
110
  };
100
111
  if (columns !== void 0) bodyLayoutConfig.columns = columns;
112
+ const finalSectionProperties = document?.package.document.sections?.at(-1)?.properties;
113
+ const finalHeaderRId = sectionHeaderFooterRefs?.at(-1)?.headerDefault;
114
+ let finalHeaderForLayout = headerContentForRender;
115
+ if (finalHeaderRId) finalHeaderForLayout = headerContentByRId?.get(finalHeaderRId);
116
+ else if (sectionHeaderFooterRefs !== void 0) finalHeaderForLayout = void 0;
117
+ const finalLayoutConfig = finalSectionProperties ? {
118
+ pageSize: getPageSize(finalSectionProperties),
119
+ margins: bodyMarginsBelowHeader(getMargins(finalSectionProperties), finalHeaderForLayout)
120
+ } : bodyLayoutConfig;
121
+ const finalColumns = getColumns(finalSectionProperties);
122
+ if (finalColumns !== void 0) finalLayoutConfig.columns = finalColumns;
101
123
  const blockMeasureInputs = computePerBlockMeasureInputs({
102
124
  blocks: newBlocks,
103
125
  bodyConfig: bodyLayoutConfig,
104
- finalConfig: bodyLayoutConfig
126
+ finalConfig: finalLayoutConfig
105
127
  });
106
128
  const blockWidths = blockMeasureInputs.widths;
107
129
  const previousArtifacts = session.artifacts;
@@ -133,22 +155,19 @@ function runLayoutPipeline(deps, state, options = {}) {
133
155
  const buildLayoutOpts = () => {
134
156
  const nextLayoutOpts = {
135
157
  pageSize,
136
- margins,
158
+ margins: initialBodyMargins,
137
159
  pageGap,
138
160
  mirrorMargins
139
161
  };
140
- if (hasTitlePg && firstPageHeaderForRender) {
141
- const headerBottom = (margins.header ?? 0) + (firstPageHeaderForRender.marginPushBottom ?? firstPageHeaderForRender.height);
142
- if (headerBottom > margins.top) nextLayoutOpts.firstPageMargins = {
143
- ...margins,
144
- top: headerBottom
162
+ if (hasTitlePg) nextLayoutOpts.firstPageMargins = bodyMarginsBelowHeader(margins, firstPageHeaderForRender);
163
+ if (finalSectionProperties) {
164
+ nextLayoutOpts.finalPageSize = finalLayoutConfig.pageSize;
165
+ nextLayoutOpts.finalMargins = finalLayoutConfig.margins;
166
+ nextLayoutOpts.finalColumns = finalLayoutConfig.columns ?? {
167
+ count: 1,
168
+ gap: 0
145
169
  };
146
170
  }
147
- const finalSection = document?.package.document.sections?.at(-1);
148
- if (finalSection) {
149
- nextLayoutOpts.finalPageSize = getPageSize(finalSection.properties);
150
- nextLayoutOpts.finalMargins = getMargins(finalSection.properties);
151
- }
152
171
  if (columns !== void 0) nextLayoutOpts.columns = columns;
153
172
  if (bodyBreakType !== void 0) nextLayoutOpts.bodyBreakType = bodyBreakType;
154
173
  if (sectionHeaderFooterRefs !== void 0) nextLayoutOpts.sectionHeaderFooterRefs = sectionHeaderFooterRefs;
@@ -1,9 +1,9 @@
1
- import { elementToXml, findChild, findDeep, getChildElements, getLocalName, mergeXmlnsDeclarations } from "./xmlParser.js";
1
+ import { elementToXml, findChild, getChildElements, getLocalName, mergeXmlnsDeclarations } from "./xmlParser.js";
2
2
  import { parseBookmarkEnd, parseBookmarkStart } from "./bookmarkParser.js";
3
- import { getTextBoxContentElement, isTextBoxDrawing, parseTextBox, parseTextBoxContent } from "./textBoxParser.js";
4
3
  import { parseSdtProperties } from "./sdtProperties.js";
5
4
  import { parseParagraph } from "./paragraphParser.js";
6
5
  import { appendBookmarkMarkerToLastParagraphInBlocks, prependBookmarkMarkersToFirstParagraphInBlocks } from "./bookmarkPlacement.js";
6
+ import { enrichParagraphTextBoxes } from "./paragraphTextBoxEnrichment.js";
7
7
  import { parseTable } from "./tableParser.js";
8
8
  import { padDecimal } from "./numberingParser.js";
9
9
  import { convertBulletToUnicode } from "./bulletMarkers.js";
@@ -100,104 +100,6 @@ const computeListMarker = (paragraph, numbering, listCounters, abstractCounters)
100
100
  }
101
101
  listRendering.marker = computedMarker;
102
102
  };
103
- const enrichParagraphTextBoxes = (paragraph, paraXml, styles, theme, numbering, rels, media) => {
104
- const xmlChildren = getChildElements(paraXml);
105
- let parsedIndex = 0;
106
- let lastConsumedRun;
107
- for (const xmlChild of xmlChildren) {
108
- if (getLocalName(xmlChild.name ?? "") !== "r") {
109
- if (parsedIndex < paragraph.content.length && paragraph.content[parsedIndex]?.type !== "run") parsedIndex += 1;
110
- continue;
111
- }
112
- const { textBoxDrawings, hasNonTextBoxContent } = scanRunForTextBoxDrawings(xmlChild);
113
- const parsedContent = paragraph.content[parsedIndex];
114
- const parsedRun = parsedContent?.type === "run" ? parsedContent : void 0;
115
- const targetRun = parsedRun ?? (hasNonTextBoxContent ? lastConsumedRun : void 0);
116
- for (const runEl of textBoxDrawings) {
117
- const textBox = parseTextBox(runEl);
118
- if (!textBox) continue;
119
- const wsp = findDeep(runEl, "wps", "wsp");
120
- if (wsp) {
121
- const txbxContentEl = getTextBoxContentElement(wsp);
122
- if (txbxContentEl) textBox.content = parseTextBoxContent(txbxContentEl, parseParagraph, null, styles, theme, numbering, rels ?? void 0, media ?? void 0);
123
- }
124
- const shape = {
125
- type: "shape",
126
- shapeType: "textBox",
127
- size: textBox.size,
128
- ...textBox.position !== void 0 ? { position: textBox.position } : {},
129
- ...textBox.wrap !== void 0 ? { wrap: textBox.wrap } : {},
130
- ...textBox.fill !== void 0 ? { fill: textBox.fill } : {},
131
- ...textBox.outline !== void 0 ? { outline: textBox.outline } : {},
132
- textBody: {
133
- content: textBox.content,
134
- ...textBox.margins !== void 0 ? { margins: textBox.margins } : {}
135
- }
136
- };
137
- if (textBox.id) shape.id = textBox.id;
138
- const shapeContent = {
139
- type: "shape",
140
- shape
141
- };
142
- if (targetRun && hasNonTextBoxContent) targetRun.content.push(shapeContent);
143
- else {
144
- const newRun = {
145
- type: "run",
146
- content: [shapeContent]
147
- };
148
- paragraph.content.splice(parsedIndex, 0, newRun);
149
- lastConsumedRun = newRun;
150
- parsedIndex += 1;
151
- }
152
- }
153
- if (hasNonTextBoxContent && parsedRun) {
154
- lastConsumedRun = parsedRun;
155
- parsedIndex += 1;
156
- }
157
- }
158
- };
159
- const scanRunForTextBoxDrawings = (xmlRun) => {
160
- const textBoxDrawings = [];
161
- let hasNonTextBoxContent = false;
162
- const visitDrawing = (drawingEl) => {
163
- if (isTextBoxDrawing(drawingEl)) {
164
- textBoxDrawings.push(drawingEl);
165
- return;
166
- }
167
- hasNonTextBoxContent = true;
168
- };
169
- for (const el of getChildElements(xmlRun)) {
170
- const name = getLocalName(el.name ?? "");
171
- if (name === "rPr") continue;
172
- if (name === "drawing") {
173
- visitDrawing(el);
174
- continue;
175
- }
176
- if (name === "AlternateContent") {
177
- const branches = getChildElements(el);
178
- const choice = branches.find((branch) => getLocalName(branch.name ?? "") === "Choice");
179
- const fallback = branches.find((branch) => getLocalName(branch.name ?? "") === "Fallback");
180
- const tryBranch = (branch) => {
181
- if (!branch) return false;
182
- let found = false;
183
- for (const innerEl of getChildElements(branch)) if (getLocalName(innerEl.name ?? "") === "drawing") {
184
- visitDrawing(innerEl);
185
- found = true;
186
- }
187
- return found;
188
- };
189
- let foundInBranch = tryBranch(choice);
190
- if (!foundInBranch) foundInBranch = tryBranch(fallback);
191
- if (!foundInBranch) hasNonTextBoxContent = true;
192
- continue;
193
- }
194
- hasNonTextBoxContent = true;
195
- }
196
- return {
197
- textBoxDrawings,
198
- hasNonTextBoxContent
199
- };
200
- };
201
103
  const parseBlockContent = (parent, styles, theme, numbering, rels, media, options) => parseBlockContentWithState(parent, styles, theme, numbering, rels, media, {
202
104
  listCounters: /* @__PURE__ */ new Map(),
203
105
  abstractCounters: /* @__PURE__ */ new Map(),
@@ -3,6 +3,6 @@ import { XmlElement } from "./xmlParser.js";
3
3
 
4
4
  //#region src/docx/groupDrawingParser.d.ts
5
5
  /** Parse a WordprocessingGroup drawing into a safe SVG-backed image preview. */
6
- declare const parseGroupDrawing: (drawing: XmlElement) => document_d_exports.Image | null;
6
+ declare const parseGroupDrawing: (drawing: XmlElement, rels?: document_d_exports.RelationshipMap, media?: Map<string, document_d_exports.MediaFile>) => document_d_exports.Image | null;
7
7
  //#endregion
8
8
  export { parseGroupDrawing };
@@ -1,6 +1,6 @@
1
- import { findAllDeep, findChildByLocalName, findChildrenByLocalName, getAttribute, getLocalName, getTextContent, parseNumericAttribute } from "./xmlParser.js";
1
+ import { findAllDeep, findChildByLocalName, findChildrenByLocalName, getAttribute, getChildElements, getLocalName, getTextContent, parseNumericAttribute } from "./xmlParser.js";
2
2
  import { emuToPixels } from "../utils/units.js";
3
- import { parseImage } from "./imageParser.js";
3
+ import { parseImage, resolveImageData } from "./imageParser.js";
4
4
  //#region src/docx/groupDrawingParser.ts
5
5
  const HEX_COLOR = /^[0-9A-Fa-f]{6}$/u;
6
6
  const DEFAULT_TEXT_COLOR = "000000";
@@ -8,6 +8,7 @@ const DEFAULT_FONT_HALF_POINTS = 22;
8
8
  const DEFAULT_LINE_WIDTH_EMU = 9525;
9
9
  const HALF_POINT_TO_EMU = 6350;
10
10
  const MAX_GROUP_SHAPES = 256;
11
+ const CROP_SCALE = 1e5;
11
12
  const MAX_PATH_COMMANDS = 1e4;
12
13
  const MAX_TEXT_CHARACTERS = 2e4;
13
14
  const MAX_SVG_CHARACTERS = 1e6;
@@ -110,18 +111,58 @@ const renderTextBox = (wsp) => {
110
111
  const lineStep = lineHeight / fontSize * svgFontSize;
111
112
  return `<text x="0" y="${svgFontSize}" transform="translate(${x} ${y}) scale(${scale})" font-family="Arial, sans-serif" font-size="${svgFontSize}" fill="#${color}">${lines.map((line, index) => `<tspan x="0" dy="${index === 0 ? 0 : lineStep}">${line}</tspan>`).join("")}</text>`;
112
113
  };
113
- const createSvg = (group, width, height) => {
114
- const content = findChildrenByLocalName(group, "wsp").slice(0, MAX_GROUP_SHAPES).map((wsp) => findChildByLocalName(wsp, "txbx") ? renderTextBox(wsp) : renderGeometry(wsp)).join("");
115
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${emuToPixels(width)}" height="${emuToPixels(height)}">${content}</svg>`;
114
+ const renderPicture = (picture, index, rels, media) => {
115
+ const { x, y, width, height } = childTransform(picture);
116
+ if (width <= 0 || height <= 0) return "";
117
+ const blipFill = findChildByLocalName(picture, "blipFill");
118
+ const blip = findChildByLocalName(blipFill, "blip");
119
+ const { src } = resolveImageData(getAttribute(blip, "r", "embed") ?? getAttribute(blip, "r", "link") ?? "", rels, media);
120
+ if (!src) return "";
121
+ const sourceRect = findChildByLocalName(blipFill, "srcRect");
122
+ const left = Math.max(0, numericAttr(sourceRect, "l")) / CROP_SCALE;
123
+ const top = Math.max(0, numericAttr(sourceRect, "t")) / CROP_SCALE;
124
+ const right = Math.max(0, numericAttr(sourceRect, "r")) / CROP_SCALE;
125
+ const bottom = Math.max(0, numericAttr(sourceRect, "b")) / CROP_SCALE;
126
+ const visibleWidth = 1 - left - right;
127
+ const visibleHeight = 1 - top - bottom;
128
+ if (visibleWidth <= 0 || visibleHeight <= 0) return "";
129
+ const image = `<image x="${x - width * left / visibleWidth}" y="${y - height * top / visibleHeight}" width="${width / visibleWidth}" height="${height / visibleHeight}" href="${escapeXml(src)}" preserveAspectRatio="none"/>`;
130
+ if (left === 0 && top === 0 && right === 0 && bottom === 0) return image;
131
+ const clipId = `group-picture-${index}`;
132
+ return `<defs><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${width}" height="${height}"/></clipPath></defs><g clip-path="url(#${clipId})">${image}</g>`;
133
+ };
134
+ const groupViewBox = (group, width, height) => {
135
+ const transform = findChildByLocalName(findChildByLocalName(group, "grpSpPr"), "xfrm");
136
+ const childOffset = findChildByLocalName(transform, "chOff");
137
+ const childExtent = findChildByLocalName(transform, "chExt");
138
+ const childWidth = numericAttr(childExtent, "cx");
139
+ const childHeight = numericAttr(childExtent, "cy");
140
+ return {
141
+ x: numericAttr(childOffset, "x"),
142
+ y: numericAttr(childOffset, "y"),
143
+ width: childWidth > 0 ? childWidth : width,
144
+ height: childHeight > 0 ? childHeight : height
145
+ };
146
+ };
147
+ const createSvg = (group, width, height, rels, media) => {
148
+ const content = getChildElements(group).slice(0, MAX_GROUP_SHAPES).map((child, index) => {
149
+ const localName = getLocalName(child.name ?? "");
150
+ if (localName === "pic") return renderPicture(child, index, rels, media);
151
+ if (localName !== "wsp") return "";
152
+ return findChildByLocalName(child, "txbx") ? renderTextBox(child) : renderGeometry(child);
153
+ }).join("");
154
+ if (!content) return null;
155
+ const viewBox = groupViewBox(group, width, height);
156
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}" width="${emuToPixels(width)}" height="${emuToPixels(height)}">${content}</svg>`;
116
157
  };
117
158
  /** Parse a WordprocessingGroup drawing into a safe SVG-backed image preview. */
118
- const parseGroupDrawing = (drawing) => {
159
+ const parseGroupDrawing = (drawing, rels, media) => {
119
160
  const group = findChildByLocalName(findAllDeep(drawing, "a", "graphicData").at(0) ?? null, "wgp");
120
161
  if (!group) return null;
121
162
  const image = parseImage(drawing, void 0, void 0);
122
163
  if (!image || image.size.width <= 0 || image.size.height <= 0) return null;
123
- const svg = createSvg(group, image.size.width, image.size.height);
124
- if (svg.length > MAX_SVG_CHARACTERS) return null;
164
+ const svg = createSvg(group, image.size.width, image.size.height, rels, media);
165
+ if (!svg || svg.length > MAX_SVG_CHARACTERS) return null;
125
166
  image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
126
167
  image.mimeType = "image/svg+xml";
127
168
  image.filename = "wordprocessing-group.svg";
@@ -0,0 +1,9 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+ import { NumberingMap } from "./numberingParser.js";
3
+ import { StyleMap } from "./styleParser.js";
4
+ import { XmlElement } from "./xmlParser.js";
5
+
6
+ //#region src/docx/paragraphTextBoxEnrichment.d.ts
7
+ declare const enrichParagraphTextBoxes: (paragraph: document_d_exports.Paragraph, paraXml: XmlElement, styles: StyleMap | null, theme: document_d_exports.Theme | null, numbering: NumberingMap | null, rels: document_d_exports.RelationshipMap | null, media: Map<string, document_d_exports.MediaFile> | null) => void;
8
+ //#endregion
9
+ export { enrichParagraphTextBoxes };
@@ -0,0 +1,104 @@
1
+ import { findDeep, getChildElements, getLocalName } from "./xmlParser.js";
2
+ import { getTextBoxContentElement, isTextBoxDrawing, parseTextBox, parseTextBoxContent } from "./textBoxParser.js";
3
+ import { parseParagraph } from "./paragraphParser.js";
4
+ //#region src/docx/paragraphTextBoxEnrichment.ts
5
+ const enrichParagraphTextBoxes = (paragraph, paraXml, styles, theme, numbering, rels, media) => {
6
+ const xmlChildren = getChildElements(paraXml);
7
+ let parsedIndex = 0;
8
+ let lastConsumedRun;
9
+ for (const xmlChild of xmlChildren) {
10
+ if (getLocalName(xmlChild.name ?? "") !== "r") {
11
+ if (parsedIndex < paragraph.content.length && paragraph.content[parsedIndex]?.type !== "run") parsedIndex += 1;
12
+ continue;
13
+ }
14
+ const { textBoxDrawings, hasNonTextBoxContent } = scanRunForTextBoxDrawings(xmlChild);
15
+ const parsedContent = paragraph.content[parsedIndex];
16
+ const parsedRun = parsedContent?.type === "run" ? parsedContent : void 0;
17
+ const targetRun = parsedRun ?? (hasNonTextBoxContent ? lastConsumedRun : void 0);
18
+ for (const runEl of textBoxDrawings) {
19
+ const textBox = parseTextBox(runEl);
20
+ if (!textBox) continue;
21
+ const wsp = findDeep(runEl, "wps", "wsp");
22
+ if (wsp) {
23
+ const txbxContentEl = getTextBoxContentElement(wsp);
24
+ if (txbxContentEl) textBox.content = parseTextBoxContent(txbxContentEl, parseParagraph, null, styles, theme, numbering, rels ?? void 0, media ?? void 0);
25
+ }
26
+ const shape = {
27
+ type: "shape",
28
+ shapeType: "textBox",
29
+ size: textBox.size,
30
+ ...textBox.position !== void 0 ? { position: textBox.position } : {},
31
+ ...textBox.wrap !== void 0 ? { wrap: textBox.wrap } : {},
32
+ ...textBox.fill !== void 0 ? { fill: textBox.fill } : {},
33
+ ...textBox.outline !== void 0 ? { outline: textBox.outline } : {},
34
+ textBody: {
35
+ content: textBox.content,
36
+ ...textBox.margins !== void 0 ? { margins: textBox.margins } : {}
37
+ }
38
+ };
39
+ if (textBox.id) shape.id = textBox.id;
40
+ const shapeContent = {
41
+ type: "shape",
42
+ shape
43
+ };
44
+ if (targetRun && hasNonTextBoxContent) targetRun.content.push(shapeContent);
45
+ else {
46
+ const newRun = {
47
+ type: "run",
48
+ content: [shapeContent]
49
+ };
50
+ paragraph.content.splice(parsedIndex, 0, newRun);
51
+ lastConsumedRun = newRun;
52
+ parsedIndex += 1;
53
+ }
54
+ }
55
+ if (hasNonTextBoxContent && parsedRun) {
56
+ lastConsumedRun = parsedRun;
57
+ parsedIndex += 1;
58
+ }
59
+ }
60
+ };
61
+ const scanRunForTextBoxDrawings = (xmlRun) => {
62
+ const textBoxDrawings = [];
63
+ let hasNonTextBoxContent = false;
64
+ const visitDrawing = (drawingEl) => {
65
+ if (isTextBoxDrawing(drawingEl)) {
66
+ textBoxDrawings.push(drawingEl);
67
+ return;
68
+ }
69
+ hasNonTextBoxContent = true;
70
+ };
71
+ for (const el of getChildElements(xmlRun)) {
72
+ const name = getLocalName(el.name ?? "");
73
+ if (name === "rPr") continue;
74
+ if (name === "drawing") {
75
+ visitDrawing(el);
76
+ continue;
77
+ }
78
+ if (name === "AlternateContent") {
79
+ const branches = getChildElements(el);
80
+ const choice = branches.find((branch) => getLocalName(branch.name ?? "") === "Choice");
81
+ const fallback = branches.find((branch) => getLocalName(branch.name ?? "") === "Fallback");
82
+ const tryBranch = (branch) => {
83
+ if (!branch) return false;
84
+ let found = false;
85
+ for (const innerEl of getChildElements(branch)) if (getLocalName(innerEl.name ?? "") === "drawing") {
86
+ visitDrawing(innerEl);
87
+ found = true;
88
+ }
89
+ return found;
90
+ };
91
+ let foundInBranch = tryBranch(choice);
92
+ if (!foundInBranch) foundInBranch = tryBranch(fallback);
93
+ if (!foundInBranch) hasNonTextBoxContent = true;
94
+ continue;
95
+ }
96
+ hasNonTextBoxContent = true;
97
+ }
98
+ return {
99
+ textBoxDrawings,
100
+ hasNonTextBoxContent
101
+ };
102
+ };
103
+ //#endregion
104
+ export { enrichParagraphTextBoxes };
@@ -1,4 +1,4 @@
1
- import { cloneWithXmlnsDeclarations, elementToXml, findChild, findChildren, getAttribute, getChildElements, getTextContent, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
1
+ import { cloneWithXmlnsDeclarations, elementToXml, findAllDeep, findChild, findChildren, getAttribute, getChildElements, getTextContent, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
2
2
  import { EmphasisMarkSchema, FontThemeSchema, HighlightColorSchema, ShadingPatternSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
3
3
  import { parseImage } from "./imageParser.js";
4
4
  import { parseGroupDrawing } from "./groupDrawingParser.js";
@@ -443,7 +443,7 @@ function parseInstrText(element) {
443
443
  * `shapeParser.parseShapeFromDrawing` into a `ShapeContent`.
444
444
  */
445
445
  function parseDrawingContent(element, rels, media) {
446
- const groupImage = parseGroupDrawing(element);
446
+ const groupImage = parseGroupDrawing(element, rels ?? void 0, media ?? void 0);
447
447
  if (groupImage) return {
448
448
  type: "drawing",
449
449
  image: groupImage,
@@ -544,6 +544,15 @@ function parseRunContents(runElement, rels, media, rootXmlns = {}) {
544
544
  const alternateChildren = getChildElements(child);
545
545
  const choiceEl = alternateChildren.find((el) => getLocalName(el.name) === "Choice");
546
546
  const fallbackEl = alternateChildren.find((el) => getLocalName(el.name) === "Fallback");
547
+ const groupedChoiceDrawing = choiceEl ? getChildElements(choiceEl).find((element) => getLocalName(element.name) === "drawing" && findAllDeep(element, "wpg", "wgp").length > 0) : void 0;
548
+ if (groupedChoiceDrawing) {
549
+ const groupedDrawing = parseDrawingContent(groupedChoiceDrawing, rels, media);
550
+ if (groupedDrawing?.type === "drawing" && groupedDrawing.image.src) {
551
+ groupedDrawing.rawXml = elementToXml(child);
552
+ contents.push(groupedDrawing);
553
+ break;
554
+ }
555
+ }
547
556
  const fallbackPict = fallbackEl ? getChildElements(fallbackEl).find((el) => getLocalName(el.name) === "pict") : void 0;
548
557
  const fallbackVml = fallbackPict ? parseVmlImageContent(fallbackPict, rels, media, rootXmlns) : null;
549
558
  if (fallbackVml?.image.src) {
@@ -0,0 +1,24 @@
1
+ //#region src/docx/server/boundedArchive.d.ts
2
+ declare const DOCX_MAX_ENTRY_BYTES: number;
3
+ declare const DOCX_MAX_TOTAL_BYTES: number;
4
+ declare const DOCX_MAX_ENTRIES = 4096;
5
+ declare const DocxArchiveError_base: import("better-result").TaggedErrorClass<"DocxArchiveError", {
6
+ message: string;
7
+ reason: "load-failed" | "too-many-entries" | "entry-too-large" | "total-too-large";
8
+ cause?: unknown;
9
+ }>;
10
+ /** Error raised when a DOCX archive cannot be loaded within configured limits. */
11
+ declare class DocxArchiveError extends DocxArchiveError_base {}
12
+ type DocxArchiveOptions = {
13
+ maxEntryBytes?: number;
14
+ maxTotalBytes?: number;
15
+ maxEntries?: number;
16
+ };
17
+ type DocxArchive = {
18
+ entries: readonly string[];
19
+ readEntryString: (path: string) => Promise<string | null>;
20
+ readEntryUint8: (path: string) => Promise<Uint8Array | null>;
21
+ };
22
+ declare const loadDocxArchive: (bytes: ArrayBuffer | Uint8Array, options?: DocxArchiveOptions) => Promise<DocxArchive>;
23
+ //#endregion
24
+ export { DOCX_MAX_ENTRIES, DOCX_MAX_ENTRY_BYTES, DOCX_MAX_TOTAL_BYTES, DocxArchive, DocxArchiveError, DocxArchiveOptions, loadDocxArchive };
@@ -0,0 +1,106 @@
1
+ import { TaggedError } from "better-result";
2
+ import JSZip from "jszip";
3
+ //#region src/docx/server/boundedArchive.ts
4
+ const DOCX_MAX_ENTRY_BYTES = 128 * 1024 * 1024;
5
+ const DOCX_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
6
+ const DOCX_MAX_ENTRIES = 4096;
7
+ /** Error raised when a DOCX archive cannot be loaded within configured limits. */
8
+ var DocxArchiveError = class extends TaggedError("DocxArchiveError")() {};
9
+ const collectStream = async ({ stream, maxEntryBytes, remainingBytes, maxTotalBytes, path }) => await new Promise((resolve, reject) => {
10
+ const chunks = [];
11
+ let entryBytes = 0;
12
+ const fail = (reason, message) => {
13
+ const destroy = Reflect.get(stream, "destroy");
14
+ if (typeof destroy === "function") Reflect.apply(destroy, stream, []);
15
+ reject(new DocxArchiveError({
16
+ message,
17
+ reason
18
+ }));
19
+ };
20
+ stream.on("data", (chunk) => {
21
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
22
+ entryBytes += bytes.length;
23
+ if (entryBytes > maxEntryBytes) {
24
+ fail("entry-too-large", `DOCX entry "${path}" exceeded the ${maxEntryBytes}-byte limit`);
25
+ return;
26
+ }
27
+ if (entryBytes > remainingBytes) {
28
+ fail("total-too-large", `DOCX archive exceeded the ${maxTotalBytes}-byte cumulative limit while reading "${path}"`);
29
+ return;
30
+ }
31
+ chunks.push(bytes);
32
+ });
33
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
34
+ stream.on("error", reject);
35
+ });
36
+ const loadDocxArchive = async (bytes, options = {}) => {
37
+ const maxEntryBytes = options.maxEntryBytes ?? 134217728;
38
+ const maxTotalBytes = options.maxTotalBytes ?? 268435456;
39
+ const maxEntries = options.maxEntries ?? 4096;
40
+ let zip;
41
+ try {
42
+ zip = await JSZip.loadAsync(bytes);
43
+ } catch (cause) {
44
+ throw new DocxArchiveError({
45
+ message: "Failed to parse DOCX archive",
46
+ reason: "load-failed",
47
+ cause
48
+ });
49
+ }
50
+ const archiveEntries = Object.values(zip.files);
51
+ if (archiveEntries.length > maxEntries) throw new DocxArchiveError({
52
+ message: `DOCX archive declares ${archiveEntries.length} entries (max ${maxEntries})`,
53
+ reason: "too-many-entries"
54
+ });
55
+ let declaredTotalBytes = 0;
56
+ for (const entry of archiveEntries) {
57
+ const data = "_data" in entry ? entry._data : void 0;
58
+ const declaredBytes = typeof data === "object" && data !== null && "uncompressedSize" in data ? data.uncompressedSize : void 0;
59
+ if (typeof declaredBytes !== "number" || !Number.isFinite(declaredBytes)) {
60
+ declaredTotalBytes = NaN;
61
+ break;
62
+ }
63
+ if (declaredBytes > maxEntryBytes) throw new DocxArchiveError({
64
+ message: `DOCX entry "${entry.name}" declares ${declaredBytes} bytes (max ${maxEntryBytes})`,
65
+ reason: "entry-too-large"
66
+ });
67
+ declaredTotalBytes += declaredBytes;
68
+ }
69
+ if (Number.isFinite(declaredTotalBytes) && declaredTotalBytes > maxTotalBytes) throw new DocxArchiveError({
70
+ message: `DOCX archive declares ${declaredTotalBytes} cumulative bytes (max ${maxTotalBytes})`,
71
+ reason: "total-too-large"
72
+ });
73
+ let totalBytesRead = 0;
74
+ let readChain = Promise.resolve();
75
+ const readEntry = async (path) => {
76
+ const work = async () => {
77
+ const entry = zip.file(path);
78
+ if (!entry) return null;
79
+ const buffer = await collectStream({
80
+ stream: entry.nodeStream("nodebuffer"),
81
+ maxEntryBytes,
82
+ remainingBytes: maxTotalBytes - totalBytesRead,
83
+ maxTotalBytes,
84
+ path
85
+ });
86
+ totalBytesRead += buffer.length;
87
+ return buffer;
88
+ };
89
+ const next = readChain.then(work, work);
90
+ readChain = next.then(() => void 0, () => void 0);
91
+ return await next;
92
+ };
93
+ return {
94
+ entries: Object.freeze(archiveEntries.map(({ name }) => name)),
95
+ async readEntryString(path) {
96
+ const buffer = await readEntry(path);
97
+ return buffer === null ? null : buffer.toString("utf-8");
98
+ },
99
+ async readEntryUint8(path) {
100
+ const buffer = await readEntry(path);
101
+ return buffer === null ? null : new Uint8Array(buffer);
102
+ }
103
+ };
104
+ };
105
+ //#endregion
106
+ export { DOCX_MAX_ENTRIES, DOCX_MAX_ENTRY_BYTES, DOCX_MAX_TOTAL_BYTES, DocxArchiveError, loadDocxArchive };
@@ -0,0 +1,23 @@
1
+ //#region src/docx/server/extractDocxText.d.ts
2
+ /** Document part containing an extracted paragraph. */
3
+ type DocxParagraphSource = "header" | "body" | "footer";
4
+ /** Paragraph text and lightweight formatting metadata from a DOCX archive. */
5
+ type ExtractedDocxParagraph = {
6
+ index: number;
7
+ text: string;
8
+ source: DocxParagraphSource;
9
+ style?: string;
10
+ bold?: boolean;
11
+ fontSize?: number;
12
+ alignment?: "left" | "center" | "right" | "both";
13
+ };
14
+ /** Accepted-revision paragraph text extracted in deterministic part order. */
15
+ type ExtractedDocxText = {
16
+ paragraphs: ExtractedDocxParagraph[];
17
+ charCount: number;
18
+ view: "accepted";
19
+ };
20
+ /** Extract paragraph text and formatting metadata from a DOCX archive. */
21
+ declare const extractDocxText: (bytes: ArrayBuffer | Uint8Array) => Promise<ExtractedDocxText>;
22
+ //#endregion
23
+ export { DocxParagraphSource, ExtractedDocxParagraph, ExtractedDocxText, extractDocxText };