@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.
- package/dist/controller/layoutPipeline.js +33 -14
- package/dist/docx/blockContentParser.js +2 -100
- package/dist/docx/groupDrawingParser.d.ts +1 -1
- package/dist/docx/groupDrawingParser.js +49 -8
- package/dist/docx/paragraphTextBoxEnrichment.d.ts +9 -0
- package/dist/docx/paragraphTextBoxEnrichment.js +104 -0
- 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/docx/tableParser.js +2 -0
- package/dist/layout-bridge/convert/toFlowBlocks.js +75 -19
- package/dist/layout-bridge/sectionColumns.js +6 -1
- package/dist/layout-engine/index.js +120 -19
- 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/measureBlocks.js +3 -2
- package/dist/layout-engine/measure/measureParagraph.js +30 -12
- package/dist/layout-engine/paginator.d.ts +2 -0
- package/dist/layout-engine/paginator.js +27 -15
- package/dist/layout-engine/tableRowBreak.js +3 -0
- package/dist/layout-engine/types.d.ts +20 -5
- package/dist/layout-painter/index.js +1 -1
- package/dist/layout-painter/renderPage.js +7 -2
- package/dist/layout-painter/renderParagraph.js +93 -9
- package/dist/layout-painter/renderTable.js +88 -10
- package/dist/paged-layout/sectionBlockWidths.js +11 -3
- package/dist/prosemirror/conversion/fromProseDoc.js +11 -2
- package/dist/prosemirror/conversion/toProseDoc.js +28 -20
- package/dist/prosemirror/extensions/nodes/TableExtension.js +3 -2
- 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
|
@@ -5,12 +5,23 @@ function isVisuallyEmptyParagraph(block) {
|
|
|
5
5
|
const run = block.runs.at(0);
|
|
6
6
|
return run?.kind === "text" && run.text === "";
|
|
7
7
|
}
|
|
8
|
+
function startsTrailingTableSeparatorChain(blocks, index) {
|
|
9
|
+
const block = blocks[index];
|
|
10
|
+
if (block?.kind !== "paragraph" || !isVisuallyEmptyParagraph(block) || blocks[index - 1]?.kind !== "table") return false;
|
|
11
|
+
for (let nextIndex = index + 1; nextIndex < blocks.length; nextIndex++) {
|
|
12
|
+
const nextBlock = blocks[nextIndex];
|
|
13
|
+
if (nextBlock?.kind !== "paragraph") return false;
|
|
14
|
+
if (!isVisuallyEmptyParagraph(nextBlock)) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
8
18
|
/**
|
|
9
19
|
* Pre-scan blocks to find all keepNext chains.
|
|
10
20
|
*
|
|
11
|
-
* A
|
|
12
|
-
*
|
|
13
|
-
*
|
|
21
|
+
* A chain starts with a paragraph whose keepNext=true or with an empty
|
|
22
|
+
* separator immediately following a table. It continues through further
|
|
23
|
+
* keepNext paragraphs and structural empty separators. The first visible
|
|
24
|
+
* non-keepNext paragraph is its anchor.
|
|
14
25
|
*
|
|
15
26
|
* Returns a map from chain start index to chain info.
|
|
16
27
|
*/
|
|
@@ -21,7 +32,7 @@ function computeKeepNextChains(blocks) {
|
|
|
21
32
|
if (processed.has(i)) continue;
|
|
22
33
|
const block = blocks[i];
|
|
23
34
|
if (block.kind !== "paragraph") continue;
|
|
24
|
-
if (!block.attrs?.keepNext) continue;
|
|
35
|
+
if (!block.attrs?.keepNext && !startsTrailingTableSeparatorChain(blocks, i)) continue;
|
|
25
36
|
const memberIndices = [i];
|
|
26
37
|
let endIndex = i;
|
|
27
38
|
for (let j = i + 1; j < blocks.length; j++) {
|
|
@@ -67,6 +78,7 @@ function calculateChainHeight(chain, blocks, measures) {
|
|
|
67
78
|
if (firstBlock?.kind !== "paragraph" || firstMeasure?.kind !== "paragraph") return 0;
|
|
68
79
|
let totalHeight = (firstBlock.attrs?.spacing?.before ?? 0) + firstMeasure.totalHeight;
|
|
69
80
|
let trailingSpacing = firstBlock.attrs?.spacing?.after ?? 0;
|
|
81
|
+
const startsWithTrailingTableSeparator = blocks[firstMemberIndex - 1]?.kind === "table" && isVisuallyEmptyParagraph(firstBlock) && !firstBlock.attrs?.keepNext;
|
|
70
82
|
const successorIndices = [...chain.memberIndices.slice(1)];
|
|
71
83
|
if (chain.anchorIndex !== -1) successorIndices.push(chain.anchorIndex);
|
|
72
84
|
for (let index = 0; index < successorIndices.length; index++) {
|
|
@@ -80,6 +92,10 @@ function calculateChainHeight(chain, blocks, measures) {
|
|
|
80
92
|
if (!firstLine) return totalHeight;
|
|
81
93
|
const isAnchor = index === successorIndices.length - 1 && chain.anchorIndex !== -1;
|
|
82
94
|
const isSplittable = successorMeasure.lines.length > 1 && !successorBlock.attrs?.keepLines;
|
|
95
|
+
if (isAnchor && startsWithTrailingTableSeparator && successorBlock.attrs?.widowControl !== false && successorMeasure.lines.length > 1) {
|
|
96
|
+
const secondLine = successorMeasure.lines.at(1);
|
|
97
|
+
return totalHeight + firstLine.lineHeight + (secondLine?.lineHeight ?? 0);
|
|
98
|
+
}
|
|
83
99
|
if (isAnchor || isSplittable) return totalHeight + firstLine.lineHeight;
|
|
84
100
|
totalHeight += successorMeasure.totalHeight;
|
|
85
101
|
trailingSpacing = successorBlock.attrs?.spacing?.after ?? 0;
|
|
@@ -160,11 +160,13 @@ function hashParagraphBlock(block) {
|
|
|
160
160
|
const attrs = block.attrs;
|
|
161
161
|
if (attrs) {
|
|
162
162
|
if (attrs.alignment) parts.push(`align:${attrs.alignment}`);
|
|
163
|
+
if (attrs.outlineLevel !== void 0) parts.push(`outline:${attrs.outlineLevel}`);
|
|
163
164
|
if (attrs.indent) parts.push(`indent:${attrs.indent.left}|${attrs.indent.right}|${attrs.indent.firstLine}|${attrs.indent.hanging}`);
|
|
164
165
|
if (attrs.spacing) parts.push(`spacing:${attrs.spacing.before}|${attrs.spacing.after}|${attrs.spacing.line}|${attrs.spacing.lineRule}`);
|
|
165
166
|
if (attrs.defaultFontSize != null) parts.push(`dfs:${attrs.defaultFontSize}`);
|
|
166
167
|
if (attrs.defaultFontFamily != null) parts.push(`dff:${attrs.defaultFontFamily}`);
|
|
167
168
|
if (attrs.suppressEmptyParagraphHeight) parts.push("sup");
|
|
169
|
+
if (attrs.reserveEmptyOutlineHeight) parts.push("outline-empty-reserve");
|
|
168
170
|
const borders = attrs.borders;
|
|
169
171
|
if (borders) {
|
|
170
172
|
const signature = (border) => border ? `${border.width ?? ""},${border.style ?? ""},${border.color ?? ""}` : "";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand, tableColumnsArePinned } from "../types.js";
|
|
1
|
+
import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand, isFloatingTextBoxBlock, tableColumnsArePinned } from "../types.js";
|
|
2
2
|
import { findClearLineY, measureParagraph } from "./measureParagraph.js";
|
|
3
3
|
import { getCachedParagraphMeasure, setCachedParagraphMeasure } from "./cache.js";
|
|
4
4
|
import { getTextBoxGroupId } from "../textBoxGroup.js";
|
|
@@ -56,6 +56,7 @@ function isInlineFlowImageRun(run) {
|
|
|
56
56
|
return true;
|
|
57
57
|
}
|
|
58
58
|
function measureTableCellBlockVisualHeight(block, blockMeasure) {
|
|
59
|
+
if (block.kind === "textBox" && isFloatingTextBoxBlock(block)) return 0;
|
|
59
60
|
if (block.kind !== "paragraph" || blockMeasure.kind !== "paragraph") {
|
|
60
61
|
if ("totalHeight" in blockMeasure) return blockMeasure.totalHeight;
|
|
61
62
|
if ("height" in blockMeasure) return blockMeasure.height;
|
|
@@ -95,7 +96,7 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
|
|
|
95
96
|
columnWidths = Array.from({ length: colCount }, () => equalWidth);
|
|
96
97
|
} else if (columnWidths.length > 0 && explicitWidthPx) {
|
|
97
98
|
const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0);
|
|
98
|
-
if (totalWidth > 0 && Math.abs(totalWidth - explicitWidthPx) > 1) {
|
|
99
|
+
if (!(tableBlock.layout !== "fixed" && (tableBlock.widthType === void 0 || tableBlock.widthType === "dxa") && totalWidth - explicitWidthPx > 1) && totalWidth > 0 && Math.abs(totalWidth - explicitWidthPx) > 1) {
|
|
99
100
|
const scale = explicitWidthPx / totalWidth;
|
|
100
101
|
columnWidths = columnWidths.map((w) => w * scale);
|
|
101
102
|
}
|
|
@@ -329,20 +329,34 @@ function uppercaseLetterRatio(text) {
|
|
|
329
329
|
}
|
|
330
330
|
return letters === 0 ? 0 : uppercase / letters;
|
|
331
331
|
}
|
|
332
|
+
function fixedSpaceAdjustedShrinkTolerance({ tolerance, regularSpaceCount, nonBreakingSpaceCount }) {
|
|
333
|
+
const totalSpaces = regularSpaceCount + nonBreakingSpaceCount;
|
|
334
|
+
if (totalSpaces === 0) return tolerance;
|
|
335
|
+
return Math.max(JUSTIFY_SHRINK_TOLERANCE_RATIO, tolerance * (regularSpaceCount / totalSpaces));
|
|
336
|
+
}
|
|
332
337
|
function justifyShrinkToleranceRatio(block, isFirstLine, regularSpaceCount, nonBreakingSpaceCount) {
|
|
333
338
|
if (block.attrs?.listMarker !== void 0) {
|
|
334
|
-
|
|
335
|
-
if (isFirstLine) return JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO;
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
return
|
|
339
|
+
const hanging = block.attrs.indent?.hanging ?? 0;
|
|
340
|
+
if (isFirstLine) return hanging <= DEFAULT_LIST_HANGING_INDENT_PX ? JUSTIFY_SHRINK_TOLERANCE_RATIO : JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO;
|
|
341
|
+
const left = block.attrs.indent?.left ?? 0;
|
|
342
|
+
if (hanging > 0 && left > hanging) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
|
|
343
|
+
return fixedSpaceAdjustedShrinkTolerance({
|
|
344
|
+
tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
|
|
345
|
+
regularSpaceCount,
|
|
346
|
+
nonBreakingSpaceCount
|
|
347
|
+
});
|
|
339
348
|
}
|
|
340
349
|
const hasTabStops = (block.attrs?.tabs?.length ?? 0) > 0;
|
|
341
350
|
const hasTabRuns = block.runs.some(isTabRun);
|
|
351
|
+
if (isFirstLine && hasTabRuns && (block.attrs?.indent?.hanging ?? 0) > 0) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
|
|
342
352
|
if (!isFirstLine && hasTabRuns && !hasTabStops) return JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO;
|
|
343
|
-
if (
|
|
353
|
+
if (hasTabRuns && (isFirstLine || hasTabStops)) return (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO;
|
|
344
354
|
if (uppercaseLetterRatio(block.runs.map((run) => isTextRun(run) ? run.text ?? "" : "").join("")) > ALL_CAPS_RATIO_THRESHOLD) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
|
|
345
|
-
return
|
|
355
|
+
return fixedSpaceAdjustedShrinkTolerance({
|
|
356
|
+
tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
|
|
357
|
+
regularSpaceCount,
|
|
358
|
+
nonBreakingSpaceCount
|
|
359
|
+
});
|
|
346
360
|
}
|
|
347
361
|
function trimTrailingSpacesAndTabs(text) {
|
|
348
362
|
let end = text.length;
|
|
@@ -479,15 +493,17 @@ function measureParagraph(block, maxWidth, options) {
|
|
|
479
493
|
};
|
|
480
494
|
}
|
|
481
495
|
const emptyMetrics = calculateEmptyParagraphMetrics(attrs?.defaultFontSize ?? DEFAULT_FONT_SIZE, spacing, attrs?.defaultFontFamily ?? DEFAULT_FONT_FAMILY);
|
|
496
|
+
const outlineLineHeight = attrs?.reserveEmptyOutlineHeight ? emptyMetrics.lineHeight * 2 : emptyMetrics.lineHeight;
|
|
482
497
|
lines.push({
|
|
483
498
|
fromRun: 0,
|
|
484
499
|
fromChar: 0,
|
|
485
500
|
toRun: 0,
|
|
486
501
|
toChar: 0,
|
|
487
502
|
width: 0,
|
|
488
|
-
...emptyMetrics
|
|
503
|
+
...emptyMetrics,
|
|
504
|
+
lineHeight: outlineLineHeight
|
|
489
505
|
});
|
|
490
|
-
let totalHeight =
|
|
506
|
+
let totalHeight = outlineLineHeight;
|
|
491
507
|
if (spacing?.before) totalHeight += spacing.before;
|
|
492
508
|
if (spacing?.after) totalHeight += spacing.after;
|
|
493
509
|
return {
|
|
@@ -651,8 +667,9 @@ function measureParagraph(block, maxWidth, options) {
|
|
|
651
667
|
decimalPrefixWidth
|
|
652
668
|
});
|
|
653
669
|
let tabWidth = tabResult.width;
|
|
670
|
+
const landsOnLeftIndent = tabResult.alignment === "start" && indentLeft > 0 && Math.abs(contentX + tabWidth - indentLeft) <= WIDTH_TOLERANCE;
|
|
654
671
|
const lineRightEdgeX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + currentLine.availableWidth + currentLine.leftOffset;
|
|
655
|
-
if (!hasFollowingTabOnLine(runs, runIndex) && canClampTabToRightEdge(tabResult.alignment, currentLine.width, hasPriorTabOnLine(runs, runIndex), followingWidth, currentLine.availableWidth) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
|
|
672
|
+
if (!landsOnLeftIndent && !hasFollowingTabOnLine(runs, runIndex) && canClampTabToRightEdge(tabResult.alignment, currentLine.width, hasPriorTabOnLine(runs, runIndex), followingWidth, currentLine.availableWidth) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
|
|
656
673
|
if (currentLine.width + tabWidth > currentLine.availableWidth + WIDTH_TOLERANCE) {
|
|
657
674
|
startNewLine(runIndex, 0);
|
|
658
675
|
updateMaxFont(style);
|
|
@@ -794,12 +811,13 @@ function measureParagraph(block, maxWidth, options) {
|
|
|
794
811
|
}
|
|
795
812
|
const rawGlueWidth = nextBreak === text.length && word.length > 0 && !isBreakChar(word[word.length - 1]) ? trailingGlueWidths[runIndex] ?? 0 : 0;
|
|
796
813
|
const glueWidth = rawGlueWidth > 0 && wordWidth + rawGlueWidth <= getPostWrapAvailableWidth() + widthTolerance ? rawGlueWidth : 0;
|
|
797
|
-
if (currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance) {
|
|
814
|
+
if (wordWidth > 0 && currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance) {
|
|
798
815
|
startNewLine(runIndex, charIndex);
|
|
799
816
|
updateMaxFont(lineHeightStyle);
|
|
800
817
|
}
|
|
801
818
|
currentLine.width += fullWordWidth;
|
|
802
|
-
|
|
819
|
+
const wordTrailingWhitespaceWidth = fullWordWidth - wordWidth;
|
|
820
|
+
currentLine.trailingWhitespaceWidth = wordWidth === 0 ? currentLine.trailingWhitespaceWidth + wordTrailingWhitespaceWidth : wordTrailingWhitespaceWidth;
|
|
803
821
|
currentLine.regularSpaceCount += word.split(" ").length - 1;
|
|
804
822
|
currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
|
|
805
823
|
currentLine.toRun = runIndex;
|
|
@@ -55,6 +55,8 @@ declare function createPaginator(options: PaginatorOptions): {
|
|
|
55
55
|
count: number;
|
|
56
56
|
gap: number;
|
|
57
57
|
equalWidth?: boolean;
|
|
58
|
+
widths?: number[];
|
|
59
|
+
gaps?: number[];
|
|
58
60
|
separator?: boolean;
|
|
59
61
|
}; /** Get current state. */
|
|
60
62
|
getCurrentState: () => PageState; /** Get available height in current column. */
|
|
@@ -7,12 +7,13 @@ import { panic } from "better-result";
|
|
|
7
7
|
* Tracks the current page, cursor position, and available space.
|
|
8
8
|
* Creates new pages when content doesn't fit.
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return (
|
|
10
|
+
/** Calculate active column widths, preferring authored unequal widths. */
|
|
11
|
+
function calculateColumnWidths(pageWidth, leftMargin, rightMargin, columns) {
|
|
12
|
+
if (columns.widths?.length === columns.count && columns.widths.every((width) => Number.isFinite(width) && width > 0)) return [...columns.widths];
|
|
13
|
+
const equalWidth = (pageWidth - leftMargin - rightMargin - (columns.count - 1) * columns.gap) / columns.count;
|
|
14
|
+
return Array.from({ length: columns.count }, () => equalWidth);
|
|
15
15
|
}
|
|
16
|
+
const gapAfterColumn = (columns, columnIndex) => columns.gaps?.[columnIndex] ?? columns.gap;
|
|
16
17
|
function arePageSizesEqual(left, right) {
|
|
17
18
|
return left.w === right.w && left.h === right.h;
|
|
18
19
|
}
|
|
@@ -49,9 +50,9 @@ function createPaginator(options) {
|
|
|
49
50
|
return arePageSizesEqual(state.page.size, pageSize) && areMarginsEqual(state.page.margins, getPageMargins(state.page.number));
|
|
50
51
|
}
|
|
51
52
|
if (getContentHeight() <= 0) panic("Paginator: page size and margins yield no content area");
|
|
52
|
-
let
|
|
53
|
-
function
|
|
54
|
-
|
|
53
|
+
let columnWidths = calculateColumnWidths(pageSize.w, margins.left, margins.right, columns);
|
|
54
|
+
function recalculateColumnWidths() {
|
|
55
|
+
columnWidths = calculateColumnWidths(pageSize.w, margins.left, margins.right, columns);
|
|
55
56
|
}
|
|
56
57
|
function applyPendingLayout() {
|
|
57
58
|
if (pendingPageSize) pageSize = pendingPageSize;
|
|
@@ -59,7 +60,7 @@ function createPaginator(options) {
|
|
|
59
60
|
pendingPageSize = void 0;
|
|
60
61
|
pendingMargins = void 0;
|
|
61
62
|
if (getContentHeight() <= 0) panic("Paginator: section page size and margins yield no content area");
|
|
62
|
-
|
|
63
|
+
recalculateColumnWidths();
|
|
63
64
|
}
|
|
64
65
|
function getPageMargins(pageNumber) {
|
|
65
66
|
const pageMargins = pageNumber === 1 && options.firstPageMargins ? { ...options.firstPageMargins } : { ...margins };
|
|
@@ -71,11 +72,14 @@ function createPaginator(options) {
|
|
|
71
72
|
return pageMargins;
|
|
72
73
|
}
|
|
73
74
|
let columnRegionTop = margins.top;
|
|
75
|
+
let columnRegionMaxBottom = margins.top;
|
|
74
76
|
/**
|
|
75
77
|
* Get X position for a given column index.
|
|
76
78
|
*/
|
|
77
79
|
function getColumnX(columnIndex) {
|
|
78
|
-
|
|
80
|
+
let x = states.at(-1)?.page.margins.left ?? getPageMargins(1).left;
|
|
81
|
+
for (let index = 0; index < columnIndex; index++) x += (columnWidths[index] ?? columnWidths[0] ?? 0) + gapAfterColumn(columns, index);
|
|
82
|
+
return x;
|
|
79
83
|
}
|
|
80
84
|
/**
|
|
81
85
|
* Create a new page and add it to the list.
|
|
@@ -111,6 +115,7 @@ function createPaginator(options) {
|
|
|
111
115
|
pages.push(page);
|
|
112
116
|
states.push(state);
|
|
113
117
|
columnRegionTop = topMargin;
|
|
118
|
+
columnRegionMaxBottom = topMargin;
|
|
114
119
|
if (options.onNewPage) options.onNewPage(state);
|
|
115
120
|
return state;
|
|
116
121
|
}
|
|
@@ -140,6 +145,7 @@ function createPaginator(options) {
|
|
|
140
145
|
*/
|
|
141
146
|
function advanceColumn(state) {
|
|
142
147
|
if (state.columnIndex < columns.count - 1) {
|
|
148
|
+
columnRegionMaxBottom = Math.max(columnRegionMaxBottom, state.cursorY);
|
|
143
149
|
state.columnIndex += 1;
|
|
144
150
|
state.cursorY = columnRegionTop;
|
|
145
151
|
state.trailingSpacing = 0;
|
|
@@ -156,7 +162,7 @@ function createPaginator(options) {
|
|
|
156
162
|
let state = getCurrentState();
|
|
157
163
|
const safeHeight = Number.isFinite(height) && height > 0 ? height : 0;
|
|
158
164
|
while (!fits(safeHeight, state)) {
|
|
159
|
-
if (safeHeight > state.contentBottom -
|
|
165
|
+
if (safeHeight > state.contentBottom - columnRegionTop) {
|
|
160
166
|
if (state.cursorY !== state.topMargin) state = advanceColumn(state);
|
|
161
167
|
return state;
|
|
162
168
|
}
|
|
@@ -273,12 +279,17 @@ function createPaginator(options) {
|
|
|
273
279
|
* column advancement stays below existing content (for continuous breaks).
|
|
274
280
|
*/
|
|
275
281
|
function updateColumns(newColumns) {
|
|
276
|
-
|
|
277
|
-
columnWidth = calculateColumnWidth(pageSize.w, margins.left, margins.right, columns);
|
|
282
|
+
const previousColumnCount = columns.count;
|
|
278
283
|
const state = getCurrentState();
|
|
284
|
+
const previousRegionBottom = Math.max(columnRegionMaxBottom, state.cursorY);
|
|
285
|
+
columns = newColumns;
|
|
286
|
+
recalculateColumnWidths();
|
|
279
287
|
if (columns.count > 1) state.page.columns = { ...columns };
|
|
280
288
|
else delete state.page.columns;
|
|
289
|
+
state.contentBottom = state.rawContentBottom - state.footnoteHeight;
|
|
290
|
+
if (previousColumnCount > 1) state.cursorY = previousRegionBottom;
|
|
281
291
|
columnRegionTop = state.cursorY;
|
|
292
|
+
columnRegionMaxBottom = state.cursorY;
|
|
282
293
|
state.columnIndex = 0;
|
|
283
294
|
}
|
|
284
295
|
function updatePageLayout(newPageSize, newMargins, applyImmediately = true) {
|
|
@@ -290,7 +301,7 @@ function createPaginator(options) {
|
|
|
290
301
|
if (newPageSize) pageSize = { ...newPageSize };
|
|
291
302
|
if (newMargins) margins = { ...newMargins };
|
|
292
303
|
if (getContentHeight() <= 0) panic("Paginator: section page size and margins yield no content area");
|
|
293
|
-
|
|
304
|
+
recalculateColumnWidths();
|
|
294
305
|
pendingPageSize = void 0;
|
|
295
306
|
pendingMargins = void 0;
|
|
296
307
|
}
|
|
@@ -312,7 +323,8 @@ function createPaginator(options) {
|
|
|
312
323
|
states,
|
|
313
324
|
/** Column width in pixels (use getColumnWidth() for current value after updates). */
|
|
314
325
|
get columnWidth() {
|
|
315
|
-
|
|
326
|
+
const columnIndex = states.at(-1)?.columnIndex ?? 0;
|
|
327
|
+
return columnWidths[columnIndex] ?? columnWidths[0] ?? getContentWidth();
|
|
316
328
|
},
|
|
317
329
|
/** Get current column layout (returns copy to prevent external mutation). */
|
|
318
330
|
get columns() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isFloatingTextBoxBlock } from "./types.js";
|
|
1
2
|
import { measureParagraph } from "./measure/measureParagraph.js";
|
|
2
3
|
import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "./measure/tableCellFloating.js";
|
|
3
4
|
//#region src/layout-engine/tableRowBreak.ts
|
|
@@ -74,6 +75,8 @@ function cellBreakGeometry(cell, measure) {
|
|
|
74
75
|
y = blockTop + blockMeasure.totalHeight;
|
|
75
76
|
paragraphY += blockMeasure.totalHeight;
|
|
76
77
|
} else if (blockMeasure) {
|
|
78
|
+
const block = cellBlocks?.[i];
|
|
79
|
+
if (block?.kind === "textBox" && isFloatingTextBoxBlock(block)) continue;
|
|
77
80
|
const blockHeight = getAtomicBlockHeight(blockMeasure);
|
|
78
81
|
if (blockHeight > 0) {
|
|
79
82
|
const top = y;
|
|
@@ -286,8 +286,13 @@ type ListNumPr = {
|
|
|
286
286
|
* Paragraph block attributes.
|
|
287
287
|
*/
|
|
288
288
|
type ParagraphAttrs = {
|
|
289
|
-
alignment?: "left" | "center" | "right" | "justify";
|
|
290
|
-
|
|
289
|
+
alignment?: "left" | "center" | "right" | "justify"; /** OOXML outline level (`w:outlineLvl`), where zero is the top level. */
|
|
290
|
+
outlineLevel?: number;
|
|
291
|
+
spacing?: ParagraphSpacing; /** Spacing sides resolved from OOXML automatic paragraph spacing. */
|
|
292
|
+
automaticSpacing?: {
|
|
293
|
+
before?: boolean;
|
|
294
|
+
after?: boolean;
|
|
295
|
+
};
|
|
291
296
|
/**
|
|
292
297
|
* Tracks which `spacing` sides came from inline (`<w:pPr><w:spacing>`)
|
|
293
298
|
* formatting versus inherited via paragraph style. Word collapses
|
|
@@ -300,6 +305,12 @@ type ParagraphAttrs = {
|
|
|
300
305
|
before?: boolean;
|
|
301
306
|
after?: boolean;
|
|
302
307
|
};
|
|
308
|
+
/**
|
|
309
|
+
* Whether an empty paragraph carries direct paragraph formatting in its
|
|
310
|
+
* source `w:pPr`. Word treats such a blank as authored and keeps its
|
|
311
|
+
* inherited spacing; a bare empty paragraph still collapses that spacing.
|
|
312
|
+
*/
|
|
313
|
+
hasDirectParagraphFormatting?: boolean;
|
|
303
314
|
indent?: ParagraphIndent;
|
|
304
315
|
keepNext?: boolean;
|
|
305
316
|
keepLines?: boolean;
|
|
@@ -319,7 +330,8 @@ type ParagraphAttrs = {
|
|
|
319
330
|
borders?: ParagraphBorders;
|
|
320
331
|
shading?: string;
|
|
321
332
|
tabs?: TabStop[]; /** Render structural empty paragraphs as zero-height anchors. */
|
|
322
|
-
suppressEmptyParagraphHeight?: boolean;
|
|
333
|
+
suppressEmptyParagraphHeight?: boolean; /** Reserve the reference extra line advance for a story-leading empty level-0 outline paragraph. */
|
|
334
|
+
reserveEmptyOutlineHeight?: boolean;
|
|
323
335
|
numPr?: ListNumPr;
|
|
324
336
|
listMarker?: string;
|
|
325
337
|
listIsBullet?: boolean;
|
|
@@ -846,7 +858,9 @@ type Page = {
|
|
|
846
858
|
type ColumnLayout = {
|
|
847
859
|
count: number;
|
|
848
860
|
gap: number;
|
|
849
|
-
equalWidth?: boolean; /**
|
|
861
|
+
equalWidth?: boolean; /** Authored widths for unequal-width section columns. */
|
|
862
|
+
widths?: number[]; /** Authored space after each column except the last. */
|
|
863
|
+
gaps?: number[]; /** Draw vertical separator line between columns (w:sep). */
|
|
850
864
|
separator?: boolean;
|
|
851
865
|
};
|
|
852
866
|
/**
|
|
@@ -904,7 +918,8 @@ type LayoutOptions = {
|
|
|
904
918
|
w: number;
|
|
905
919
|
h: number;
|
|
906
920
|
}; /** Body-level final section margins. */
|
|
907
|
-
finalMargins?: PageMargins; /**
|
|
921
|
+
finalMargins?: PageMargins; /** Body-level final section column configuration. */
|
|
922
|
+
finalColumns?: ColumnLayout; /** Column configuration. */
|
|
908
923
|
columns?: ColumnLayout; /** Gap between rendered pages (for UI). */
|
|
909
924
|
pageGap?: number; /** Default line height multiplier. */
|
|
910
925
|
defaultLineHeight?: number; /** Header content heights by variant. */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { FRAGMENT_CLASS_NAMES, renderFragment } from "./renderFragment.js";
|
|
2
2
|
import { IMAGE_CLASS_NAMES, renderImageFragment } from "./renderImage.js";
|
|
3
3
|
import { renderLine, renderParagraphFragment, sliceRunsForLine } from "./renderParagraph.js";
|
|
4
|
-
import { TABLE_CLASS_NAMES, renderTableFragment } from "./renderTable.js";
|
|
5
4
|
import { TEXTBOX_CLASS_NAMES, renderTextBoxFragment } from "./renderTextBox.js";
|
|
5
|
+
import { TABLE_CLASS_NAMES, renderTableFragment } from "./renderTable.js";
|
|
6
6
|
import { renderPage, renderPages } from "./renderPage.js";
|
|
7
7
|
import { prefersReducedMotionBehavior } from "../paged-layout/scrollNavigation.js";
|
|
8
8
|
import { createFeatureRegistry } from "./registry/registry.js";
|
|
@@ -8,8 +8,8 @@ import { renderFragment } from "./renderFragment.js";
|
|
|
8
8
|
import { applyImageVisualAttrs, hasImageVisualAttrs, renderImageFragment } from "./renderImage.js";
|
|
9
9
|
import { emuToPixels } from "./renderUtils.js";
|
|
10
10
|
import { renderParagraphFragment } from "./renderParagraph.js";
|
|
11
|
-
import { renderTableFragment } from "./renderTable.js";
|
|
12
11
|
import { renderTextBoxFragment } from "./renderTextBox.js";
|
|
12
|
+
import { renderTableFragment } from "./renderTable.js";
|
|
13
13
|
import { renderWatermarkLayer } from "./renderWatermark.js";
|
|
14
14
|
import { panic } from "better-result";
|
|
15
15
|
//#region src/layout-painter/renderPage.ts
|
|
@@ -560,7 +560,12 @@ function renderHeaderFooterContent(content, context, options, layout) {
|
|
|
560
560
|
...block.pmStart !== void 0 ? { pmStart: block.pmStart } : {},
|
|
561
561
|
...block.pmEnd !== void 0 ? { pmEnd: block.pmEnd } : {}
|
|
562
562
|
}, block, measure, hfContext, { document: doc });
|
|
563
|
-
|
|
563
|
+
const textBoxTop = block.position ? resolveHeaderFooterFloatTop({
|
|
564
|
+
height: measure.height,
|
|
565
|
+
paragraphY: cursorY,
|
|
566
|
+
position: block.position
|
|
567
|
+
}, layout) : cursorY;
|
|
568
|
+
fragEl.style.top = `${textBoxTop}px`;
|
|
564
569
|
fragEl.style.left = resolveHeaderFooterFloatLeft(measure.width, block.position?.horizontal, layout);
|
|
565
570
|
if (block.wrapType === "behind") fragEl.style.zIndex = "-1";
|
|
566
571
|
containerEl.append(fragEl);
|
|
@@ -709,6 +709,75 @@ function splitTextRunsByEastAsia(runs) {
|
|
|
709
709
|
}
|
|
710
710
|
return result;
|
|
711
711
|
}
|
|
712
|
+
const paintsLineEdgeSpaces = (run) => Boolean(run.underline || run.strike || run.highlight || run.shading || run.hyperlink || run.hidden || run.templatePreview || run.isInsertion || run.isDeletion || run.commentIds?.length || run.textEffect || run.emphasisMark);
|
|
713
|
+
const splitTextRunAt = (run, index) => {
|
|
714
|
+
const leading = {
|
|
715
|
+
...run,
|
|
716
|
+
text: run.text.slice(0, index)
|
|
717
|
+
};
|
|
718
|
+
const trailing = {
|
|
719
|
+
...run,
|
|
720
|
+
text: run.text.slice(index)
|
|
721
|
+
};
|
|
722
|
+
if (run.pmStart === void 0) return [leading, trailing];
|
|
723
|
+
const splitPosition = Math.min(run.pmStart + index, run.pmEnd ?? Number.POSITIVE_INFINITY);
|
|
724
|
+
leading.pmEnd = splitPosition;
|
|
725
|
+
trailing.pmStart = splitPosition;
|
|
726
|
+
return [leading, trailing];
|
|
727
|
+
};
|
|
728
|
+
/**
|
|
729
|
+
* Word keeps ordinary line-edge spaces addressable in the document model but
|
|
730
|
+
* gives them no painted advance. Collapsible trailing spaces always collapse;
|
|
731
|
+
* leading spaces collapse only after a soft wrap, not at the authored
|
|
732
|
+
* paragraph start or after a manual line break. Split paint runs to preserve
|
|
733
|
+
* exact PM ranges.
|
|
734
|
+
*/
|
|
735
|
+
const splitCollapsibleLineEdgeSpaces = (sourceRuns, collapseLeading) => {
|
|
736
|
+
const runs = [...sourceRuns];
|
|
737
|
+
const collapsedLeadingRuns = /* @__PURE__ */ new Set();
|
|
738
|
+
const collapsedTrailingRuns = /* @__PURE__ */ new Set();
|
|
739
|
+
for (let index = runs.length - 1; index >= 0; index--) {
|
|
740
|
+
const run = runs[index];
|
|
741
|
+
if (!run || !isTextRun(run)) break;
|
|
742
|
+
if (run.text.length === 0) continue;
|
|
743
|
+
const trailingSpaces = / +$/u.exec(run.text);
|
|
744
|
+
if (!trailingSpaces || paintsLineEdgeSpaces(run)) break;
|
|
745
|
+
if (trailingSpaces.index === 0) {
|
|
746
|
+
collapsedTrailingRuns.add(run);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const [leading, trailing] = splitTextRunAt(run, trailingSpaces.index);
|
|
750
|
+
runs.splice(index, 1, leading, trailing);
|
|
751
|
+
collapsedTrailingRuns.add(trailing);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
if (collapseLeading) for (let index = 0; index < runs.length; index++) {
|
|
755
|
+
const run = runs[index];
|
|
756
|
+
if (!run || !isTextRun(run)) break;
|
|
757
|
+
if (run.text.length === 0) continue;
|
|
758
|
+
const leadingSpaces = /^ +/u.exec(run.text);
|
|
759
|
+
if (!leadingSpaces || paintsLineEdgeSpaces(run)) break;
|
|
760
|
+
if (leadingSpaces[0].length === run.text.length) {
|
|
761
|
+
collapsedLeadingRuns.add(run);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const [leading, trailing] = splitTextRunAt(run, leadingSpaces[0].length);
|
|
765
|
+
runs.splice(index, 1, leading, trailing);
|
|
766
|
+
collapsedLeadingRuns.add(leading);
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
runs,
|
|
771
|
+
collapsedLeadingRuns,
|
|
772
|
+
collapsedTrailingRuns
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
const startsAfterSoftWrap = (block, line) => {
|
|
776
|
+
if (line.fromChar > 0) return true;
|
|
777
|
+
if (line.fromRun === 0) return false;
|
|
778
|
+
const previousRun = block.runs.at(line.fromRun - 1);
|
|
779
|
+
return previousRun !== void 0 && !isLineBreakRun(previousRun);
|
|
780
|
+
};
|
|
712
781
|
/**
|
|
713
782
|
* Sub-pixel tolerance when comparing canvas-measured widths against the DOM's
|
|
714
783
|
* actual right edge. Accumulated rounding from canvas measureText vs. browser
|
|
@@ -878,7 +947,20 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
878
947
|
lineEl.style.boxSizing = "content-box";
|
|
879
948
|
lineEl.style.height = `${line.lineHeight}px`;
|
|
880
949
|
lineEl.style.lineHeight = `${line.lineHeight}px`;
|
|
881
|
-
const
|
|
950
|
+
const splitRuns = splitTextRunsByEastAsia(sliceRunsForLine(block, line));
|
|
951
|
+
const { runs: runsForLine, collapsedLeadingRuns: collapsedLeadingSpaceRuns, collapsedTrailingRuns: collapsedTrailingSpaceRuns } = splitCollapsibleLineEdgeSpaces(splitRuns, startsAfterSoftWrap(block, line));
|
|
952
|
+
const isCollapsedLineEdgeSpaceRun = (run) => collapsedLeadingSpaceRuns.has(run) || collapsedTrailingSpaceRuns.has(run);
|
|
953
|
+
const renderLineTextRun = (run) => {
|
|
954
|
+
const runEl = renderTextRun(run, doc);
|
|
955
|
+
if (collapsedLeadingSpaceRuns.has(run)) runEl.dataset["collapsedLeadingSpaces"] = "true";
|
|
956
|
+
if (collapsedTrailingSpaceRuns.has(run)) runEl.dataset["collapsedTrailingSpaces"] = "true";
|
|
957
|
+
if (isCollapsedLineEdgeSpaceRun(run)) {
|
|
958
|
+
runEl.style.fontSize = "0";
|
|
959
|
+
runEl.style.letterSpacing = "0";
|
|
960
|
+
runEl.style.wordSpacing = "0";
|
|
961
|
+
}
|
|
962
|
+
return runEl;
|
|
963
|
+
};
|
|
882
964
|
const onlyRun = runsForLine.length === 1 ? runsForLine[0] : void 0;
|
|
883
965
|
if (onlyRun && isMathRun(onlyRun) && onlyRun.display === "block" && block.attrs?.alignment !== "right") lineEl.style.textAlign = "center";
|
|
884
966
|
if (runsForLine.length === 1 && isImageRun(runsForLine[0])) {
|
|
@@ -910,9 +992,11 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
910
992
|
if (!options.isLastLine || options.paragraphEndsWithLineBreak) {
|
|
911
993
|
const firstLineIndentPx = options.isFirstLine ? options.firstLineIndentPx ?? 0 : 0;
|
|
912
994
|
const firstLineHangingPx = Math.max(0, -firstLineIndentPx);
|
|
913
|
-
const
|
|
995
|
+
const hasVisibleListMarker = options.isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden;
|
|
996
|
+
const firstLineHangingExpansionPx = hasVisibleListMarker ? Math.min(firstLineHangingPx, Math.max(0, options.leftIndentPx ?? 0)) : firstLineHangingPx;
|
|
997
|
+
const justifyCapacityPx = options.availableWidth + firstLineHangingExpansionPx;
|
|
914
998
|
const overfullPx = line.width - justifyCapacityPx;
|
|
915
|
-
const shrinkableSpaces = countShrinkableSpaces(runsForLine);
|
|
999
|
+
const shrinkableSpaces = countShrinkableSpaces(runsForLine.filter((run) => !isTextRun(run) || !isCollapsedLineEdgeSpaceRun(run)));
|
|
916
1000
|
if (overfullPx > RIGHT_EDGE_EPSILON_PX && shrinkableSpaces > 0) {
|
|
917
1001
|
lineEl.style.textAlign = "left";
|
|
918
1002
|
lineEl.style.textAlignLast = "auto";
|
|
@@ -925,7 +1009,7 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
925
1009
|
lineEl.style.textAlign = "justify";
|
|
926
1010
|
lineEl.style.textAlignLast = "justify";
|
|
927
1011
|
}
|
|
928
|
-
const listFirstLineOffset =
|
|
1012
|
+
const listFirstLineOffset = hasVisibleListMarker ? Math.max(firstLineIndentPx, -firstLineHangingExpansionPx) : 0;
|
|
929
1013
|
lineEl.style.width = `${options.availableWidth - listFirstLineOffset}px`;
|
|
930
1014
|
}
|
|
931
1015
|
}
|
|
@@ -987,7 +1071,7 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
987
1071
|
for (let j = i + 1; j < runsForLine.length; j++) {
|
|
988
1072
|
const next = runsForLine[j];
|
|
989
1073
|
if (isTabRun(next) || isLineBreakRun(next)) break;
|
|
990
|
-
if (isTextRun(next)) lineEl.append(
|
|
1074
|
+
if (isTextRun(next)) lineEl.append(renderLineTextRun(next));
|
|
991
1075
|
else if (isFieldRun(next) && options?.context) lineEl.append(renderFieldRun(next, doc, options.context));
|
|
992
1076
|
else if (isImageRun(next)) {
|
|
993
1077
|
if (isFloatingImageRun(next)) continue;
|
|
@@ -1001,13 +1085,14 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
1001
1085
|
break;
|
|
1002
1086
|
}
|
|
1003
1087
|
let tabWidth = tabResult.width;
|
|
1004
|
-
if (lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
|
|
1088
|
+
if (!(tabResult.alignment === "start" && leftIndentPx > 0 && Math.abs(currentX + tabWidth - leftIndentPx) <= RIGHT_EDGE_EPSILON_PX) && lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
|
|
1005
1089
|
const tabEl = renderTabRun(run, doc, tabWidth, tabResult.leader);
|
|
1006
1090
|
lineEl.append(tabEl);
|
|
1007
1091
|
currentX += tabWidth;
|
|
1008
1092
|
} else if (isTextRun(run)) {
|
|
1009
|
-
const runEl =
|
|
1093
|
+
const runEl = renderLineTextRun(run);
|
|
1010
1094
|
lineEl.append(runEl);
|
|
1095
|
+
if (isCollapsedLineEdgeSpaceRun(run)) continue;
|
|
1011
1096
|
const fontSize = run.fontSize || 11;
|
|
1012
1097
|
const fontFamily = run.fontFamily || "Calibri";
|
|
1013
1098
|
const measuredWidth = measureText(run.allCaps ? run.text.toLocaleUpperCase() : run.text, fontSize, fontFamily, {
|
|
@@ -1249,7 +1334,6 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
|
|
|
1249
1334
|
} else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
|
|
1250
1335
|
else if (hasFirstLine && !isFlexLine) lineEl.style.textIndent = `${indent.firstLine ?? 0}px`;
|
|
1251
1336
|
} else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
|
|
1252
|
-
else if (hasHanging && indentLeft === 0) lineEl.style.paddingLeft = `${indent.hanging ?? 0}px`;
|
|
1253
1337
|
if (indentRight > 0) lineEl.style.paddingRight = `${indentRight}px`;
|
|
1254
1338
|
if (isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden) {
|
|
1255
1339
|
const hanging = indent?.hanging ?? 0;
|
|
@@ -1269,7 +1353,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
|
|
|
1269
1353
|
const markerFontSize = block.attrs.listMarkerFontSize ?? firstTextRun?.fontSize ?? block.attrs.defaultFontSize;
|
|
1270
1354
|
const marker = renderListMarker(block.attrs.listMarker, getListMarkerInlineWidth(block), doc, markerFontFamily, markerFontSize, block.attrs.listMarkerRevision, block.attrs.listMarkerSecondSlotOffsetTwips);
|
|
1271
1355
|
const markerMarginLeft = markerStart - Math.min(indentLeft, 0);
|
|
1272
|
-
if (markerMarginLeft < 0
|
|
1356
|
+
if (markerMarginLeft < 0) marker.style.marginLeft = `${markerMarginLeft}px`;
|
|
1273
1357
|
lineEl.prepend(marker);
|
|
1274
1358
|
}
|
|
1275
1359
|
fragmentEl.append(lineEl);
|