@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.
- package/dist/controller/layoutPipeline.js +18 -10
- package/dist/docx/groupDrawingParser.d.ts +1 -1
- package/dist/docx/groupDrawingParser.js +49 -8
- package/dist/docx/runParser.js +11 -2
- package/dist/docx/server/boundedArchive.d.ts +24 -0
- package/dist/docx/server/boundedArchive.js +106 -0
- package/dist/docx/server/extractDocxText.d.ts +23 -0
- package/dist/docx/server/extractDocxText.js +154 -0
- package/dist/layout-bridge/convert/toFlowBlocks.js +64 -10
- package/dist/layout-engine/index.js +115 -16
- package/dist/layout-engine/keep-together.d.ts +7 -5
- package/dist/layout-engine/keep-together.js +20 -4
- package/dist/layout-engine/measure/cache.js +2 -0
- package/dist/layout-engine/measure/measureParagraph.js +30 -12
- package/dist/layout-engine/paginator.js +10 -2
- package/dist/layout-engine/types.d.ts +15 -3
- package/dist/layout-painter/renderParagraph.js +88 -5
- package/dist/prosemirror/conversion/toProseDoc.js +2 -0
- package/dist/prosemirror/extensions/nodes/TableExtension.js +1 -0
- package/dist/prosemirror/schema/nodes.d.ts +2 -1
- package/dist/prosemirror/utils/tabCalculator.js +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +3 -1
- package/dist/utils/formatToStyle.js +3 -3
- package/dist/utils/units.d.ts +6 -6
- package/dist/utils/units.js +8 -8
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
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";
|
package/dist/docx/runParser.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
|
1220
|
-
|
|
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
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
/**
|