@stll/folio-core 0.6.1 → 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.
@@ -19,6 +19,15 @@ import { tryBuildIncrementalMeasures } from "../paged-layout/incrementalMeasure.
19
19
  import { computePerBlockMeasureInputs } from "../paged-layout/sectionBlockWidths.js";
20
20
  import { getDocumentWatermark } from "../watermark/index.js";
21
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
+ }
22
31
  function runLayoutPipeline(deps, state, options = {}) {
23
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;
24
33
  let outcome = {};
@@ -92,17 +101,22 @@ function runLayoutPipeline(deps, state, options = {}) {
92
101
  let firstPageFooterForRender = hasTitlePg ? renderHfFromContentOrPm(firstPageFooterContent, firstPageFooterContentRId, hfPMs, contentWidth, hfMetricsFooter, hfOptions) : void 0;
93
102
  let headerContentByRId = renderHeaderFooterContentByRId(document?.package.headers, hfPMs, contentWidth, hfMetricsHeader, hfOptions);
94
103
  let footerContentByRId = renderHeaderFooterContentByRId(document?.package.footers, hfPMs, contentWidth, hfMetricsFooter, hfOptions);
104
+ const initialBodyMargins = bodyMarginsBelowHeader(margins, headerContentForRender);
95
105
  recordPhaseDuration("header-footer", phaseStartedAt);
96
106
  phaseStartedAt = performance.now();
97
107
  const bodyLayoutConfig = {
98
108
  pageSize,
99
- margins
109
+ margins: initialBodyMargins
100
110
  };
101
111
  if (columns !== void 0) bodyLayoutConfig.columns = columns;
102
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;
103
117
  const finalLayoutConfig = finalSectionProperties ? {
104
118
  pageSize: getPageSize(finalSectionProperties),
105
- margins: getMargins(finalSectionProperties)
119
+ margins: bodyMarginsBelowHeader(getMargins(finalSectionProperties), finalHeaderForLayout)
106
120
  } : bodyLayoutConfig;
107
121
  const finalColumns = getColumns(finalSectionProperties);
108
122
  if (finalColumns !== void 0) finalLayoutConfig.columns = finalColumns;
@@ -141,17 +155,11 @@ function runLayoutPipeline(deps, state, options = {}) {
141
155
  const buildLayoutOpts = () => {
142
156
  const nextLayoutOpts = {
143
157
  pageSize,
144
- margins,
158
+ margins: initialBodyMargins,
145
159
  pageGap,
146
160
  mirrorMargins
147
161
  };
148
- if (hasTitlePg && firstPageHeaderForRender) {
149
- const headerBottom = (margins.header ?? 0) + (firstPageHeaderForRender.marginPushBottom ?? firstPageHeaderForRender.height);
150
- if (headerBottom > margins.top) nextLayoutOpts.firstPageMargins = {
151
- ...margins,
152
- top: headerBottom
153
- };
154
- }
162
+ if (hasTitlePg) nextLayoutOpts.firstPageMargins = bodyMarginsBelowHeader(margins, firstPageHeaderForRender);
155
163
  if (finalSectionProperties) {
156
164
  nextLayoutOpts.finalPageSize = finalLayoutConfig.pageSize;
157
165
  nextLayoutOpts.finalMargins = finalLayoutConfig.margins;
@@ -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";
@@ -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 };
@@ -0,0 +1,154 @@
1
+ import { findAllDeep, findChild, findDeep, getAttributeAnyPrefix, getLocalName, getTextContent, parseXml } from "../xmlParser.js";
2
+ import { loadDocxArchive } from "./boundedArchive.js";
3
+ //#region src/docx/server/extractDocxText.ts
4
+ const HEADER_FOOTER_PATH = /^word\/(?:header|footer)\d+\.xml$/u;
5
+ const childElements = (element) => element.elements?.filter((child) => child.type === "element") ?? [];
6
+ const collectText = (element) => {
7
+ let text = "";
8
+ const walk = (node) => {
9
+ const localName = getLocalName(node.name ?? "");
10
+ if (localName === "t") {
11
+ text += getTextContent(node);
12
+ return;
13
+ }
14
+ if (localName === "br") {
15
+ text += "\n";
16
+ return;
17
+ }
18
+ if (localName === "tab") {
19
+ text += " ";
20
+ return;
21
+ }
22
+ if (localName === "del" || localName === "delText" || localName === "moveFrom") return;
23
+ for (const child of childElements(node)) walk(child);
24
+ };
25
+ walk(element);
26
+ return text;
27
+ };
28
+ const readParagraphProperties = (paragraph) => {
29
+ const properties = findChild(paragraph, "w", "pPr");
30
+ if (!properties) return {};
31
+ const result = {};
32
+ const styleValue = getAttributeAnyPrefix(findChild(properties, "w", "pStyle"), "val");
33
+ if (styleValue !== null) result.style = styleValue;
34
+ const alignment = getAttributeAnyPrefix(findChild(properties, "w", "jc"), "val");
35
+ if (alignment === "left" || alignment === "center" || alignment === "right" || alignment === "both") result.alignment = alignment;
36
+ return result;
37
+ };
38
+ const readRunMetrics = (paragraph) => {
39
+ const metrics = [];
40
+ for (const run of childElements(paragraph)) {
41
+ if (getLocalName(run.name ?? "") !== "r") continue;
42
+ const properties = findChild(run, "w", "rPr");
43
+ const boldProperty = findChild(properties, "w", "b");
44
+ const boldValue = getAttributeAnyPrefix(boldProperty, "val");
45
+ const bold = boldProperty !== null && boldValue !== "0" && boldValue !== "false";
46
+ const sizeValue = getAttributeAnyPrefix(findChild(properties, "w", "sz"), "val");
47
+ const parsedSize = sizeValue === null ? NaN : Number.parseInt(sizeValue, 10);
48
+ const fontSize = Number.isFinite(parsedSize) && parsedSize > 0 ? parsedSize : void 0;
49
+ let chars = 0;
50
+ for (const textNode of findAllDeep(run, "w", "t")) chars += getTextContent(textNode).length;
51
+ if (chars === 0) continue;
52
+ const entry = {
53
+ bold,
54
+ chars
55
+ };
56
+ if (fontSize !== void 0) entry.fontSize = fontSize;
57
+ metrics.push(entry);
58
+ }
59
+ return metrics;
60
+ };
61
+ const extractContainer = ({ container, source, startIndex }) => {
62
+ const paragraphs = [];
63
+ let charCount = 0;
64
+ for (const [offset, paragraph] of findAllDeep(container, "w", "p").entries()) {
65
+ const text = collectText(paragraph);
66
+ const entry = {
67
+ index: startIndex + offset,
68
+ text,
69
+ source
70
+ };
71
+ const { style, alignment } = readParagraphProperties(paragraph);
72
+ if (style !== void 0) entry.style = style;
73
+ if (alignment !== void 0) entry.alignment = alignment;
74
+ const runs = readRunMetrics(paragraph);
75
+ if (runs.length > 0) {
76
+ const totalChars = runs.reduce((sum, run) => sum + run.chars, 0);
77
+ if (runs.reduce((sum, run) => sum + (run.bold ? run.chars : 0), 0) > totalChars / 2) entry.bold = true;
78
+ const firstFontSize = runs.find((run) => run.fontSize !== void 0)?.fontSize;
79
+ if (firstFontSize !== void 0) entry.fontSize = firstFontSize;
80
+ }
81
+ paragraphs.push(entry);
82
+ charCount += text.length;
83
+ }
84
+ return {
85
+ paragraphs,
86
+ charCount
87
+ };
88
+ };
89
+ const extractParts = async ({ archive, source, rootName, startIndex }) => {
90
+ const paragraphs = [];
91
+ let charCount = 0;
92
+ let nextIndex = startIndex;
93
+ const prefix = `word/${source}`;
94
+ const paths = archive.entries.filter((path) => HEADER_FOOTER_PATH.test(path) && path.startsWith(prefix)).toSorted();
95
+ for (const path of paths) {
96
+ const xml = await archive.readEntryString(path);
97
+ if (xml === null) continue;
98
+ const container = findDeep(parseXml(xml), "w", rootName);
99
+ if (!container) continue;
100
+ const result = extractContainer({
101
+ container,
102
+ source,
103
+ startIndex: nextIndex
104
+ });
105
+ paragraphs.push(...result.paragraphs);
106
+ charCount += result.charCount;
107
+ nextIndex += result.paragraphs.length;
108
+ }
109
+ return {
110
+ paragraphs,
111
+ charCount
112
+ };
113
+ };
114
+ const createEmptyResult = () => ({
115
+ paragraphs: [],
116
+ charCount: 0,
117
+ view: "accepted"
118
+ });
119
+ /** Extract paragraph text and formatting metadata from a DOCX archive. */
120
+ const extractDocxText = async (bytes) => {
121
+ const archive = await loadDocxArchive(bytes);
122
+ const documentXml = await archive.readEntryString("word/document.xml");
123
+ if (documentXml === null) return createEmptyResult();
124
+ const body = findDeep(parseXml(documentXml), "w", "body");
125
+ if (!body) return createEmptyResult();
126
+ const headers = await extractParts({
127
+ archive,
128
+ source: "header",
129
+ rootName: "hdr",
130
+ startIndex: 0
131
+ });
132
+ const bodyResult = extractContainer({
133
+ container: body,
134
+ source: "body",
135
+ startIndex: headers.paragraphs.length
136
+ });
137
+ const footers = await extractParts({
138
+ archive,
139
+ source: "footer",
140
+ rootName: "ftr",
141
+ startIndex: headers.paragraphs.length + bodyResult.paragraphs.length
142
+ });
143
+ return {
144
+ paragraphs: [
145
+ ...headers.paragraphs,
146
+ ...bodyResult.paragraphs,
147
+ ...footers.paragraphs
148
+ ],
149
+ charCount: headers.charCount + bodyResult.charCount + footers.charCount,
150
+ view: "accepted"
151
+ };
152
+ };
153
+ //#endregion
154
+ export { extractDocxText };
@@ -1,5 +1,5 @@
1
1
  import { NUMBER_FORMAT_VALUES } from "../../types/documentEnumValues.js";
2
- import { halfPointsToPixels, halfPointsToPoints, pointsToPixels } from "../../utils/units.js";
2
+ import { AUTO_PARAGRAPH_SPACING_PX, halfPointsToPixels, halfPointsToPoints, pointsToPixels } from "../../utils/units.js";
3
3
  import { padDecimal } from "../../docx/numberingParser.js";
4
4
  import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
5
5
  import { resolveColor, resolveHighlightToCss } from "../../utils/colorResolver.js";
@@ -673,6 +673,7 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
673
673
  else if (align === "center") attrs.alignment = "center";
674
674
  else if (align === "right") attrs.alignment = "right";
675
675
  }
676
+ if (typeof pmAttrs.outlineLevel === "number") attrs.outlineLevel = pmAttrs.outlineLevel;
676
677
  const spaceBefore = pmAttrs.spaceBefore;
677
678
  const spaceAfter = pmAttrs.spaceAfter;
678
679
  const lineSpacing = pmAttrs.lineSpacing;
@@ -680,10 +681,14 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
680
681
  const autoAfter = autospacingMatchesBase(pmAttrs._autospacingBase, "after", spaceAfter);
681
682
  if (autoBefore || autoAfter || typeof spaceBefore === "number" || typeof spaceAfter === "number" || typeof lineSpacing === "number") {
682
683
  attrs.spacing = {};
683
- if (autoBefore) attrs.spacing.before = 14;
684
+ if (autoBefore) attrs.spacing.before = AUTO_PARAGRAPH_SPACING_PX;
684
685
  else if (typeof spaceBefore === "number") attrs.spacing.before = twipsToPixels(spaceBefore);
685
- if (autoAfter) attrs.spacing.after = 14;
686
+ if (autoAfter) attrs.spacing.after = AUTO_PARAGRAPH_SPACING_PX;
686
687
  else if (typeof spaceAfter === "number") attrs.spacing.after = twipsToPixels(spaceAfter);
688
+ if (autoBefore || autoAfter) attrs.automaticSpacing = {
689
+ ...autoBefore ? { before: true } : {},
690
+ ...autoAfter ? { after: true } : {}
691
+ };
687
692
  const pmSpacingExplicit = pmAttrs.spacingExplicit;
688
693
  const spacingFromDocDefaults = pmAttrs.spacingFromDocDefaults;
689
694
  const explicit = {};
@@ -703,7 +708,7 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
703
708
  let indentLeft = typeof pmAttrs.indentLeft === "number" ? pmAttrs.indentLeft : void 0;
704
709
  let indentFirstLine = typeof pmAttrs.indentFirstLine === "number" ? pmAttrs.indentFirstLine : void 0;
705
710
  let hangingIndent = pmAttrs.hangingIndent;
706
- if (pmAttrs.numPr?.numId && indentLeft === void 0) {
711
+ if (pmAttrs.numPr?.numId && indentLeft === void 0 && indentFirstLine === void 0) {
707
712
  indentLeft = ((pmAttrs.numPr.ilvl ?? 0) + 1) * 720;
708
713
  if (indentFirstLine === void 0) {
709
714
  indentFirstLine = -360;
@@ -829,6 +834,7 @@ function convertParagraph(node, startPos, options) {
829
834
  const attrs = convertParagraphAttrs(pmAttrs, options.theme, options.listCounters, options.listAbstractCounters, options.listSeenNumIds, options.defaultTabStopTwips, options.originalListCounters, options.originalListAbstractCounters, options.originalListSeenNumIds);
830
835
  const defaultTextFormatting = pmAttrs.defaultTextFormatting;
831
836
  if (runs.length === 0) {
837
+ if (pmAttrs._originalFormatting && Object.entries(pmAttrs._originalFormatting).some(([key, value]) => key !== "runProperties" && value !== void 0 && value !== null)) attrs.hasDirectParagraphFormatting = true;
832
838
  const paragraphMarkFormatting = pmAttrs._originalFormatting?.runProperties;
833
839
  if (paragraphMarkFormatting?.fontSize !== void 0) attrs.defaultFontSize = paragraphMarkFormatting.fontSize / 2;
834
840
  const paragraphMarkFontFamily = paragraphMarkFormatting?.fontFamily?.ascii ?? paragraphMarkFormatting?.fontFamily?.hAnsi;
@@ -851,6 +857,37 @@ function convertParagraph(node, startPos, options) {
851
857
  };
852
858
  }
853
859
  /**
860
+ * Word keeps terminal empty body paragraphs after a final table as editable
861
+ * anchors, but they do not create a page of their own. Preserve every block
862
+ * and PM range while collapsing only the contiguous, run-free suffix; empty
863
+ * paragraphs elsewhere still retain their normal line height.
864
+ */
865
+ function isPaintlessTerminalParagraph(block) {
866
+ if (block?.kind !== "paragraph" || block.runs.length !== 0) return false;
867
+ const attrs = block.attrs;
868
+ return !(attrs?.listMarker !== void 0 && !attrs.listMarkerHidden || attrs?.borders?.top || attrs?.borders?.bottom || attrs?.borders?.left || attrs?.borders?.right || attrs?.borders?.between || attrs?.borders?.bar || attrs?.shading || attrs?.spacingExplicit?.before || attrs?.spacingExplicit?.after || attrs?.pageBreakBefore || attrs?.renderedPageBreakBefore);
869
+ }
870
+ function suppressTerminalEmptyParagraphsAfterTable(blocks) {
871
+ let suffixStart = blocks.length;
872
+ while (suffixStart > 0 && isPaintlessTerminalParagraph(blocks[suffixStart - 1])) suffixStart -= 1;
873
+ if (suffixStart === blocks.length || suffixStart === 0 || blocks[suffixStart - 1]?.kind !== "table") return;
874
+ for (let index = suffixStart; index < blocks.length; index += 1) {
875
+ const block = blocks[index];
876
+ if (isPaintlessTerminalParagraph(block)) block.attrs = {
877
+ ...block.attrs,
878
+ suppressEmptyParagraphHeight: true
879
+ };
880
+ }
881
+ }
882
+ function reserveLeadingEmptyOutlineHeight(blocks) {
883
+ const firstBlock = blocks.at(0);
884
+ if (firstBlock?.kind !== "paragraph" || firstBlock.runs.length !== 0 || firstBlock.attrs?.outlineLevel !== 0) return;
885
+ firstBlock.attrs = {
886
+ ...firstBlock.attrs,
887
+ reserveEmptyOutlineHeight: true
888
+ };
889
+ }
890
+ /**
854
891
  * Convert border width from eighths of a point to pixels.
855
892
  * OOXML stores border widths in eighths of a point.
856
893
  */
@@ -1001,7 +1038,8 @@ function convertTable(node, startPos, options) {
1001
1038
  }
1002
1039
  const justification = attrs.justification;
1003
1040
  const originalFormatting = attrs._originalFormatting;
1004
- const indentPx = originalFormatting?.indent?.value && originalFormatting.indent.type === "dxa" ? twipsToPixels(originalFormatting.indent.value) : void 0;
1041
+ const effectiveIndent = attrs._resolvedIndent ?? originalFormatting?.indent;
1042
+ const indentPx = effectiveIndent?.value !== void 0 && effectiveIndent?.type === "dxa" ? twipsToPixels(effectiveIndent.value) : void 0;
1005
1043
  const floating = attrs.floating;
1006
1044
  let floatingPx;
1007
1045
  if (floating) {
@@ -1216,8 +1254,12 @@ function toFlowBlocks(doc, options = {}) {
1216
1254
  switch (node.type.name) {
1217
1255
  case "paragraph": {
1218
1256
  const pmAttrs = expectParagraphAttrs(node);
1219
- const onlyChild = node.childCount === 1 ? node.firstChild : null;
1220
- if (onlyChild?.type.name === "hardBreak" && expectHardBreakAttrs(onlyChild).breakType === "column") {
1257
+ const secProps = pmAttrs._sectionProperties;
1258
+ const hasSectionBreak = secProps !== void 0 || pmAttrs.sectionBreakType !== null && pmAttrs.sectionBreakType !== void 0;
1259
+ const hasListFormatting = pmAttrs.numPr !== null && pmAttrs.numPr !== void 0 || pmAttrs.listMarker !== null && pmAttrs.listMarker !== void 0;
1260
+ const firstChild = node.firstChild;
1261
+ const startsWithColumnBreak = firstChild?.type.name === "hardBreak" && expectHardBreakAttrs(firstChild).breakType === "column";
1262
+ if (node.childCount === 1 && startsWithColumnBreak) {
1221
1263
  const columnBreak = {
1222
1264
  kind: "columnBreak",
1223
1265
  id: nextBlockId(),
@@ -1225,9 +1267,19 @@ function toFlowBlocks(doc, options = {}) {
1225
1267
  pmEnd: pos + node.nodeSize
1226
1268
  };
1227
1269
  trackedPush(columnBreak);
1228
- } else trackedPush(convertParagraph(node, pos, opts));
1229
- const secProps = pmAttrs._sectionProperties;
1230
- if (secProps || pmAttrs.sectionBreakType) {
1270
+ } else if (startsWithColumnBreak && firstChild) {
1271
+ const columnBreak = {
1272
+ kind: "columnBreak",
1273
+ id: nextBlockId(),
1274
+ pmStart: pos + 1,
1275
+ pmEnd: pos + 1 + firstChild.nodeSize
1276
+ };
1277
+ trackedPush(columnBreak);
1278
+ const paragraph = convertParagraph(node, pos, opts);
1279
+ if (paragraph.runs.at(0)?.kind === "lineBreak") paragraph.runs.shift();
1280
+ trackedPush(paragraph);
1281
+ } else if (node.content.size > 0 || hasListFormatting || !hasSectionBreak) trackedPush(convertParagraph(node, pos, opts));
1282
+ if (hasSectionBreak) {
1231
1283
  const sectionBreak = {
1232
1284
  kind: "sectionBreak",
1233
1285
  id: nextBlockId()
@@ -1288,6 +1340,8 @@ function toFlowBlocks(doc, options = {}) {
1288
1340
  doc.forEach((node, nodeOffset) => {
1289
1341
  visit(node, offset + nodeOffset);
1290
1342
  });
1343
+ reserveLeadingEmptyOutlineHeight(blocks);
1344
+ suppressTerminalEmptyParagraphsAfterTable(blocks);
1291
1345
  return mergeRunInParagraphs(blocks);
1292
1346
  }
1293
1347
  /**