@polotno/pdf-export 0.1.37 → 0.1.39
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/README.md +61 -8
- package/lib/index.d.ts +66 -8
- package/lib/index.js +25 -145
- package/package.json +17 -18
- package/lib/browser-entry.d.ts +0 -7
- package/lib/browser-entry.js +0 -11
- package/lib/compare-render.d.ts +0 -1
- package/lib/compare-render.js +0 -185
- package/lib/core/index.d.ts +0 -26
- package/lib/core/index.js +0 -87
- package/lib/figure.d.ts +0 -10
- package/lib/figure.js +0 -54
- package/lib/filters.d.ts +0 -2
- package/lib/filters.js +0 -163
- package/lib/ghostscript.d.ts +0 -21
- package/lib/ghostscript.js +0 -132
- package/lib/group.d.ts +0 -5
- package/lib/group.js +0 -5
- package/lib/image.d.ts +0 -38
- package/lib/image.js +0 -279
- package/lib/line.d.ts +0 -10
- package/lib/line.js +0 -66
- package/lib/platform/adapter.d.ts +0 -37
- package/lib/platform/adapter.js +0 -13
- package/lib/platform/browser-polyfill.d.ts +0 -1
- package/lib/platform/browser-polyfill.js +0 -5
- package/lib/platform/browser.d.ts +0 -7
- package/lib/platform/browser.js +0 -145
- package/lib/platform/node.d.ts +0 -7
- package/lib/platform/node.js +0 -142
- package/lib/spot-colors.d.ts +0 -38
- package/lib/spot-colors.js +0 -141
- package/lib/svg-render.d.ts +0 -9
- package/lib/svg-render.js +0 -63
- package/lib/svg.d.ts +0 -12
- package/lib/svg.js +0 -224
- package/lib/text/fonts.d.ts +0 -16
- package/lib/text/fonts.js +0 -82
- package/lib/text/index.d.ts +0 -8
- package/lib/text/index.js +0 -42
- package/lib/text/layout.d.ts +0 -22
- package/lib/text/layout.js +0 -522
- package/lib/text/parser.d.ts +0 -46
- package/lib/text/parser.js +0 -415
- package/lib/text/render.d.ts +0 -8
- package/lib/text/render.js +0 -237
- package/lib/text/types.d.ts +0 -91
- package/lib/text/types.js +0 -1
- package/lib/text.d.ts +0 -49
- package/lib/text.js +0 -1277
- package/lib/utils.d.ts +0 -16
- package/lib/utils.js +0 -124
package/lib/text/fonts.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { srcToBuffer } from '../utils.js';
|
|
2
|
-
import getUrls from 'get-urls';
|
|
3
|
-
import fetch from 'node-fetch';
|
|
4
|
-
const fontUrlRegistry = {};
|
|
5
|
-
export function registerFontUrl(fontFamily, url) {
|
|
6
|
-
fontUrlRegistry[fontFamily] = url;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Get font weight string based on bold/italic state
|
|
10
|
-
*/
|
|
11
|
-
export function getFontWeight(bold, italic, baseFontWeight) {
|
|
12
|
-
if (bold) {
|
|
13
|
-
return 'bold';
|
|
14
|
-
}
|
|
15
|
-
return baseFontWeight || 'normal';
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Get font key for caching
|
|
19
|
-
*/
|
|
20
|
-
export function getFontKey(fontFamily, bold, italic, baseFontWeight) {
|
|
21
|
-
const weight = getFontWeight(bold, italic, baseFontWeight);
|
|
22
|
-
const style = italic ? 'italic' : 'normal';
|
|
23
|
-
return `${fontFamily}-${weight}-${style}`;
|
|
24
|
-
}
|
|
25
|
-
export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
|
|
26
|
-
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
27
|
-
const italicParam = italic ? 'italic' : '';
|
|
28
|
-
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
|
|
29
|
-
const req = await fetch(url);
|
|
30
|
-
if (!req.ok) {
|
|
31
|
-
if (weight !== '400' || italic) {
|
|
32
|
-
// Fallback: try normal weight without italic
|
|
33
|
-
return getGoogleFontPath(fontFamily, 'normal', false);
|
|
34
|
-
}
|
|
35
|
-
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
36
|
-
}
|
|
37
|
-
const text = await req.text();
|
|
38
|
-
const urls = getUrls(text);
|
|
39
|
-
return urls.values().next().value;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Load font for a text element or segment
|
|
43
|
-
*/
|
|
44
|
-
export async function loadFontForSegment(doc, segment, element, fonts) {
|
|
45
|
-
const fontFamily = element.fontFamily;
|
|
46
|
-
// Determine bold/italic from segment or element
|
|
47
|
-
const bold = segment
|
|
48
|
-
? segment.bold || element.fontWeight == 'bold' || false
|
|
49
|
-
: element.fontWeight == 'bold';
|
|
50
|
-
const italic = segment
|
|
51
|
-
? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
|
|
52
|
-
: element.fontStyle?.indexOf('italic') >= 0 || false;
|
|
53
|
-
// Check if universal font is already defined
|
|
54
|
-
if (fonts[fontFamily]) {
|
|
55
|
-
doc.font(fontFamily);
|
|
56
|
-
return fontFamily;
|
|
57
|
-
}
|
|
58
|
-
const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
|
|
59
|
-
if (!fonts[fontKey]) {
|
|
60
|
-
let src;
|
|
61
|
-
if (fontUrlRegistry[fontFamily]) {
|
|
62
|
-
src = fontUrlRegistry[fontFamily];
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
const weight = getFontWeight(bold, italic, element.fontWeight);
|
|
66
|
-
src = await getGoogleFontPath(fontFamily, weight, italic);
|
|
67
|
-
}
|
|
68
|
-
try {
|
|
69
|
-
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
throw new Error(`Failed to load font "${fontFamily}" from ${src}: ${error.message}`);
|
|
73
|
-
}
|
|
74
|
-
fonts[fontKey] = true;
|
|
75
|
-
}
|
|
76
|
-
doc.font(fontKey);
|
|
77
|
-
return fontKey;
|
|
78
|
-
}
|
|
79
|
-
// Alias for backward compatibility
|
|
80
|
-
export async function loadFontIfNeeded(doc, element, fonts) {
|
|
81
|
-
return loadFontForSegment(doc, null, element, fonts);
|
|
82
|
-
}
|
package/lib/text/index.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { TextElement, RenderAttrs } from './types.js';
|
|
2
|
-
export * from './types.js';
|
|
3
|
-
export { loadFontIfNeeded } from './fonts.js';
|
|
4
|
-
export { normalizeRichText, parseHTMLToSegments } from './parser.js';
|
|
5
|
-
/**
|
|
6
|
-
* Main text rendering function
|
|
7
|
-
*/
|
|
8
|
-
export declare function renderText(doc: PDFKit.PDFDocument, element: TextElement, fonts: Record<string, boolean>, attrs?: RenderAttrs): Promise<void>;
|
package/lib/text/index.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { normalizeRichText } from './parser.js';
|
|
2
|
-
import { calculateTextMetrics, calculateVerticalAlignment, fitTextToHeight, } from './layout.js';
|
|
3
|
-
import { renderTextBackground, renderPDFX1aStroke, renderStandardStroke, renderTextFill, } from './render.js';
|
|
4
|
-
export * from './types.js';
|
|
5
|
-
export { loadFontIfNeeded } from './fonts.js';
|
|
6
|
-
export { normalizeRichText, parseHTMLToSegments } from './parser.js';
|
|
7
|
-
/**
|
|
8
|
-
* Main text rendering function
|
|
9
|
-
*/
|
|
10
|
-
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
11
|
-
let elementToRender = element;
|
|
12
|
-
if (typeof element.text === 'string') {
|
|
13
|
-
const normalizedText = normalizeRichText(element.text);
|
|
14
|
-
if (normalizedText !== element.text) {
|
|
15
|
-
elementToRender = { ...element, text: normalizedText };
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
doc.fontSize(elementToRender.fontSize);
|
|
19
|
-
const hasStroke = elementToRender.strokeWidth > 0;
|
|
20
|
-
const isPDFX1a = attrs.pdfx1a;
|
|
21
|
-
// Calculate text metrics and line positioning
|
|
22
|
-
const metrics = calculateTextMetrics(doc, elementToRender);
|
|
23
|
-
const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
|
|
24
|
-
// Fit text to element height if needed
|
|
25
|
-
fitTextToHeight(doc, elementToRender, metrics.textOptions);
|
|
26
|
-
// Calculate final vertical offset
|
|
27
|
-
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
28
|
-
// Render background if enabled
|
|
29
|
-
renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
|
|
30
|
-
// Render text based on stroke and PDF/X-1a requirements
|
|
31
|
-
if (hasStroke && isPDFX1a) {
|
|
32
|
-
// PDF/X-1a mode: simulate stroke with offset fills
|
|
33
|
-
await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
// Standard rendering: stroke first, then fill
|
|
37
|
-
if (hasStroke) {
|
|
38
|
-
await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
39
|
-
}
|
|
40
|
-
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
41
|
-
}
|
|
42
|
-
}
|
package/lib/text/layout.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { TextElement, TextLine, TextMetrics, RenderSegment, TabExpansionSegment } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Expand tabs in text with word spacing adjustment for accurate visual alignment.
|
|
4
|
-
*/
|
|
5
|
-
export declare function expandTabsWithWordSpacing(text: string, doc: PDFKit.PDFDocument, textOptions: PDFKit.Mixins.TextOptions, tabSizeInSpaces?: number, currentWidth?: number): {
|
|
6
|
-
segments: TabExpansionSegment[];
|
|
7
|
-
finalWidth: number;
|
|
8
|
-
};
|
|
9
|
-
export declare function buildRenderSegmentsForLine(doc: PDFKit.PDFDocument, element: TextElement, lineText: string, textOptions: PDFKit.Mixins.TextOptions, fonts: Record<string, boolean>): Promise<RenderSegment[]>;
|
|
10
|
-
export declare function splitTextIntoLines(doc: PDFKit.PDFDocument, element: TextElement, props: PDFKit.Mixins.TextOptions): TextLine[];
|
|
11
|
-
export declare function calculateTextMetrics(doc: PDFKit.PDFDocument, element: TextElement): TextMetrics;
|
|
12
|
-
export declare function calculateVerticalAlignment(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): number;
|
|
13
|
-
export declare function fitTextToHeight(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): void;
|
|
14
|
-
/**
|
|
15
|
-
* Calculate X offset for list markers (not used for text content positioning)
|
|
16
|
-
*/
|
|
17
|
-
export declare function calculateLineXOffset(element: TextElement, line: TextLine): number;
|
|
18
|
-
export declare function calculateTextContentXOffset(element: TextElement, line: TextLine): number;
|
|
19
|
-
/**
|
|
20
|
-
* Calculate effective width for text rendering, considering justify and underline constraints
|
|
21
|
-
*/
|
|
22
|
-
export declare function calculateEffectiveWidth(element: TextElement, line: TextLine, widthOption: number | undefined, hasUnderline: boolean): number | undefined;
|
package/lib/text/layout.js
DELETED
|
@@ -1,522 +0,0 @@
|
|
|
1
|
-
import { stripHtml } from 'string-strip-html';
|
|
2
|
-
import { tokenizeHTML, parseHtmlToParagraphs, tokensToHTML, decodeHtmlEntities, parseHTMLToSegments, } from './parser.js';
|
|
3
|
-
import { loadFontForSegment } from './fonts.js';
|
|
4
|
-
/**
|
|
5
|
-
* Expand tabs to spaces based on character positions (every 8 characters by default).
|
|
6
|
-
* This is a lightweight character-based approximation used for line-breaking calculations.
|
|
7
|
-
*/
|
|
8
|
-
function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
|
|
9
|
-
if (!text) {
|
|
10
|
-
return text;
|
|
11
|
-
}
|
|
12
|
-
let result = '';
|
|
13
|
-
let position = startPosition; // Current character position
|
|
14
|
-
for (let i = 0; i < text.length; i++) {
|
|
15
|
-
const char = text[i];
|
|
16
|
-
if (char === '\t') {
|
|
17
|
-
// Calculate how many spaces needed to reach next tab stop
|
|
18
|
-
const spacesNeeded = tabSize - (position % tabSize);
|
|
19
|
-
result += ' '.repeat(spacesNeeded);
|
|
20
|
-
position += spacesNeeded;
|
|
21
|
-
}
|
|
22
|
-
else if (char === '\n') {
|
|
23
|
-
// Reset position on newline (tab stops reset at line start)
|
|
24
|
-
result += char;
|
|
25
|
-
position = 0;
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
result += char;
|
|
29
|
-
position++;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return result;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Expand tabs in text with word spacing adjustment for accurate visual alignment.
|
|
36
|
-
*/
|
|
37
|
-
export function expandTabsWithWordSpacing(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0) {
|
|
38
|
-
if (!text || !text.includes('\t')) {
|
|
39
|
-
// No tabs, return as-is
|
|
40
|
-
const width = currentWidth + doc.widthOfString(text, textOptions);
|
|
41
|
-
return {
|
|
42
|
-
segments: [{ type: 'text', text, wordSpacing: 0 }],
|
|
43
|
-
finalWidth: width,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
// Measure the width of one space character (for rendering tab spaces)
|
|
47
|
-
const spaceWidth = doc.widthOfString(' ', textOptions);
|
|
48
|
-
const tabStopWidth = Math.max(spaceWidth * tabSizeInSpaces, spaceWidth || 1);
|
|
49
|
-
const segments = [];
|
|
50
|
-
let width = currentWidth;
|
|
51
|
-
let currentSegment = '';
|
|
52
|
-
for (let i = 0; i < text.length; i++) {
|
|
53
|
-
const char = text[i];
|
|
54
|
-
if (char === '\t') {
|
|
55
|
-
// Flush current segment if any
|
|
56
|
-
if (currentSegment) {
|
|
57
|
-
const segmentWidth = doc.widthOfString(currentSegment, textOptions);
|
|
58
|
-
segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
|
|
59
|
-
width += segmentWidth;
|
|
60
|
-
currentSegment = '';
|
|
61
|
-
}
|
|
62
|
-
// Calculate the exact distance to next tab stop
|
|
63
|
-
const currentTabPosition = width % tabStopWidth;
|
|
64
|
-
const targetWidth = tabStopWidth - currentTabPosition;
|
|
65
|
-
const widthBeforeTab = width;
|
|
66
|
-
// Emit a tab instruction so the renderer can manually reposition the cursor.
|
|
67
|
-
segments.push({
|
|
68
|
-
type: 'tab',
|
|
69
|
-
advanceWidth: widthBeforeTab + targetWidth,
|
|
70
|
-
});
|
|
71
|
-
width += targetWidth;
|
|
72
|
-
}
|
|
73
|
-
else if (char === '\n') {
|
|
74
|
-
// Flush current segment and add newline
|
|
75
|
-
if (currentSegment) {
|
|
76
|
-
const segmentWidth = doc.widthOfString(currentSegment, textOptions);
|
|
77
|
-
segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
|
|
78
|
-
width += segmentWidth;
|
|
79
|
-
currentSegment = '';
|
|
80
|
-
}
|
|
81
|
-
segments.push({ type: 'text', text: '\n', wordSpacing: 0 });
|
|
82
|
-
width = 0; // Reset width on newline
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
currentSegment += char;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
// Flush remaining segment
|
|
89
|
-
if (currentSegment) {
|
|
90
|
-
const segmentWidth = doc.widthOfString(currentSegment, textOptions);
|
|
91
|
-
segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
|
|
92
|
-
width += segmentWidth;
|
|
93
|
-
}
|
|
94
|
-
return { segments, finalWidth: width };
|
|
95
|
-
}
|
|
96
|
-
export async function buildRenderSegmentsForLine(doc, element, lineText, textOptions, fonts) {
|
|
97
|
-
const parsedSegments = parseHTMLToSegments(lineText, element);
|
|
98
|
-
let currentLineWidth = 0;
|
|
99
|
-
const renderSegments = [];
|
|
100
|
-
for (const segment of parsedSegments) {
|
|
101
|
-
const fontKey = await loadFontForSegment(doc, segment, element, fonts);
|
|
102
|
-
doc.font(fontKey);
|
|
103
|
-
doc.fontSize(element.fontSize);
|
|
104
|
-
if (segment.text.includes('\t')) {
|
|
105
|
-
const expanded = expandTabsWithWordSpacing(segment.text, doc, textOptions, 8, currentLineWidth);
|
|
106
|
-
currentLineWidth = expanded.finalWidth;
|
|
107
|
-
for (const tabSegment of expanded.segments) {
|
|
108
|
-
if (tabSegment.type === 'tab') {
|
|
109
|
-
renderSegments.push({
|
|
110
|
-
segment,
|
|
111
|
-
fontKey,
|
|
112
|
-
type: 'tab',
|
|
113
|
-
advanceWidth: tabSegment.advanceWidth,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
renderSegments.push({
|
|
118
|
-
segment,
|
|
119
|
-
fontKey,
|
|
120
|
-
type: 'text',
|
|
121
|
-
text: tabSegment.text,
|
|
122
|
-
wordSpacing: tabSegment.wordSpacing,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
const segmentWidth = doc.widthOfString(segment.text, textOptions);
|
|
129
|
-
currentLineWidth += segmentWidth;
|
|
130
|
-
renderSegments.push({
|
|
131
|
-
segment,
|
|
132
|
-
fontKey,
|
|
133
|
-
type: 'text',
|
|
134
|
-
text: segment.text,
|
|
135
|
-
wordSpacing: 0,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return renderSegments;
|
|
140
|
-
}
|
|
141
|
-
function cloneListMetaForLine(meta, showMarker) {
|
|
142
|
-
if (!meta) {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
...meta,
|
|
147
|
-
showMarker,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
function createListLineMeta(doc, element, props, paragraphMeta) {
|
|
151
|
-
const indentPx = paragraphMeta.indentLevel * element.fontSize * 0.5;
|
|
152
|
-
const markerText = paragraphMeta.type === 'ul' ? '•' : `${paragraphMeta.index.toString()}.`;
|
|
153
|
-
const previousFontSize = doc._fontSize !== undefined
|
|
154
|
-
? doc._fontSize
|
|
155
|
-
: element.fontSize;
|
|
156
|
-
const markerFontSize = paragraphMeta.type === 'ul' ? element.fontSize * 1.2 : element.fontSize;
|
|
157
|
-
doc.fontSize(markerFontSize);
|
|
158
|
-
const markerLabelWidth = doc.widthOfString(markerText, {
|
|
159
|
-
...props,
|
|
160
|
-
width: undefined,
|
|
161
|
-
});
|
|
162
|
-
doc.fontSize(previousFontSize);
|
|
163
|
-
const markerGapPx = element.fontSize * (paragraphMeta.type === 'ul' ? 1.5 : 0.8);
|
|
164
|
-
const markerBoxMinPx = element.fontSize * (paragraphMeta.type === 'ul' ? 2.5 : 2.8);
|
|
165
|
-
const markerBoxWidth = Math.max(markerLabelWidth + markerGapPx, markerBoxMinPx);
|
|
166
|
-
const textStartPx = indentPx + markerBoxWidth;
|
|
167
|
-
return {
|
|
168
|
-
type: paragraphMeta.type,
|
|
169
|
-
markerText,
|
|
170
|
-
indentPx,
|
|
171
|
-
markerBoxWidth,
|
|
172
|
-
markerFontSize,
|
|
173
|
-
markerAlignment: paragraphMeta.type === 'ul' ? 'center' : 'right',
|
|
174
|
-
showMarker: paragraphMeta.displayMarker,
|
|
175
|
-
textStartPx,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
function encodeHtmlEntities(text) {
|
|
179
|
-
return text
|
|
180
|
-
.replace(/&/g, '&')
|
|
181
|
-
.replace(/</g, '<')
|
|
182
|
-
.replace(/>/g, '>')
|
|
183
|
-
.replace(/"/g, '"')
|
|
184
|
-
.replace(/'/g, ''');
|
|
185
|
-
}
|
|
186
|
-
export function splitTextIntoLines(doc, element, props) {
|
|
187
|
-
const lines = [];
|
|
188
|
-
const rawText = typeof element.text === 'string'
|
|
189
|
-
? element.text
|
|
190
|
-
: String(element.text ?? '');
|
|
191
|
-
const paragraphs = parseHtmlToParagraphs(rawText);
|
|
192
|
-
const lineHeightPx = element.lineHeight * element.fontSize;
|
|
193
|
-
// Calculate max allowed lines. Ensure at least 1 line is allowed.
|
|
194
|
-
// Use a very large number if height check is disabled or infinite
|
|
195
|
-
// If element.height is very large (like in 8.json which is a page), we should treat it as effectively infinite for text
|
|
196
|
-
// But wait, the element in 8.json has specific height:
|
|
197
|
-
// "height": 60 for title, 40 for subtitle, 40 for chapter title, 600 for content text.
|
|
198
|
-
// The content text has height 600 and font size 14. Line height 1.5 -> 21px per line.
|
|
199
|
-
// 600 / 21 = ~28 lines.
|
|
200
|
-
// So maxLines is finite.
|
|
201
|
-
const maxLines = element.height > 0
|
|
202
|
-
? Math.max(1, Math.round(element.height / lineHeightPx))
|
|
203
|
-
: Infinity;
|
|
204
|
-
if (paragraphs.length === 0) {
|
|
205
|
-
paragraphs.push({ html: '' });
|
|
206
|
-
}
|
|
207
|
-
for (const paragraph of paragraphs) {
|
|
208
|
-
// Tokenize the paragraph
|
|
209
|
-
const tokens = tokenizeHTML(paragraph.html);
|
|
210
|
-
// Extract plain text for width calculation
|
|
211
|
-
// Expand tabs to tab stops for accurate width measurement
|
|
212
|
-
const plainText = expandTabsToTabStops(tokens
|
|
213
|
-
.filter((t) => t.type === 'text')
|
|
214
|
-
.map((t) => t.decodedContent ?? decodeHtmlEntities(t.content))
|
|
215
|
-
.join(''), 8);
|
|
216
|
-
const baseMeta = paragraph.listMeta
|
|
217
|
-
? createListLineMeta(doc, element, props, paragraph.listMeta)
|
|
218
|
-
: undefined;
|
|
219
|
-
const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
|
|
220
|
-
const availableWidth = element.align === 'justify'
|
|
221
|
-
? Math.max(availableWidthRaw, 1)
|
|
222
|
-
: Math.max(availableWidthRaw, element.width * 0.1, 1);
|
|
223
|
-
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
224
|
-
let showMarkerForLine = baseMeta?.showMarker ?? false;
|
|
225
|
-
// Justify alignment using native pdfkit instruments
|
|
226
|
-
if (paragraphWidth <= availableWidth || element.align === 'justify') {
|
|
227
|
-
// Paragraph fits on one line
|
|
228
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
229
|
-
lines.push({
|
|
230
|
-
text: paragraph.html,
|
|
231
|
-
width: paragraphWidth,
|
|
232
|
-
fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
233
|
-
listMeta,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
// Need to split paragraph into multiple lines
|
|
238
|
-
let currentLineDecoded = '';
|
|
239
|
-
let currentWidth = 0;
|
|
240
|
-
let currentTokens = [];
|
|
241
|
-
let openTags = [];
|
|
242
|
-
for (const token of tokens) {
|
|
243
|
-
if (token.type === 'tag') {
|
|
244
|
-
currentTokens.push(token);
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
// Text token - split by words
|
|
248
|
-
// Don't expand tabs here - we need to preserve tabs for proper alignment
|
|
249
|
-
const rawWords = token.content.split(' ');
|
|
250
|
-
const decodedText = token.decodedContent ?? decodeHtmlEntities(token.content);
|
|
251
|
-
const decodedWords = decodedText.split(' ');
|
|
252
|
-
for (let i = 0; i < rawWords.length; i++) {
|
|
253
|
-
const rawWord = rawWords[i];
|
|
254
|
-
const decodedWord = decodedWords[i] ?? decodeHtmlEntities(rawWord);
|
|
255
|
-
const separator = i > 0 ? ' ' : '';
|
|
256
|
-
const hasCurrentLine = currentLineDecoded.length > 0;
|
|
257
|
-
const testLineDecoded = hasCurrentLine
|
|
258
|
-
? `${currentLineDecoded}${separator}${decodedWord}`
|
|
259
|
-
: decodedWord;
|
|
260
|
-
// Expand tabs in test line for accurate width measurement
|
|
261
|
-
// Tabs are expanded based on the full line position, maintaining tab stop alignment
|
|
262
|
-
const testLineExpanded = expandTabsToTabStops(testLineDecoded, 8);
|
|
263
|
-
const testWidth = doc.widthOfString(testLineExpanded, props);
|
|
264
|
-
if (testWidth <= availableWidth) {
|
|
265
|
-
currentLineDecoded = testLineDecoded;
|
|
266
|
-
currentWidth = testWidth;
|
|
267
|
-
// Add text token (with space if not first word in token)
|
|
268
|
-
const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
|
|
269
|
-
const decodedContent = separator.length > 0 ? `${separator}${decodedWord}` : decodedWord;
|
|
270
|
-
currentTokens.push({
|
|
271
|
-
type: 'text',
|
|
272
|
-
content: rawContent,
|
|
273
|
-
decodedContent,
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
// Line is too long, save current line and start new one
|
|
278
|
-
// Check if we can add a new line (wrapping).
|
|
279
|
-
// If we wrap, we push the current line and start a new one.
|
|
280
|
-
// So we will have lines.length + 1 lines + the new one we are starting.
|
|
281
|
-
// We can wrap only if lines.length + 1 <= maxLines.
|
|
282
|
-
if (lines.length < maxLines - 1) {
|
|
283
|
-
// Case 1: Line has content, so we wrap to next line
|
|
284
|
-
if (currentLineDecoded.length > 0) {
|
|
285
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
286
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
287
|
-
lines.push({
|
|
288
|
-
text: result.html,
|
|
289
|
-
width: currentWidth,
|
|
290
|
-
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
291
|
-
listMeta,
|
|
292
|
-
});
|
|
293
|
-
openTags = result.openTags;
|
|
294
|
-
currentTokens = [];
|
|
295
|
-
showMarkerForLine = false;
|
|
296
|
-
// Handle the current word on the new line
|
|
297
|
-
currentLineDecoded = decodedWord;
|
|
298
|
-
// Expand tabs for accurate width measurement
|
|
299
|
-
const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
|
|
300
|
-
currentWidth = doc.widthOfString(decodedWordExpanded, props);
|
|
301
|
-
currentTokens.push({
|
|
302
|
-
type: 'text',
|
|
303
|
-
content: rawWord,
|
|
304
|
-
decodedContent: decodedWord,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
// Case 2: Line is empty (start of line), but word is too big for line
|
|
309
|
-
// Since we can wrap (have height), but the word itself is wider than line width,
|
|
310
|
-
// we must split the word across lines.
|
|
311
|
-
// The original logic for splitting word was inside "else if (currentLineDecoded.length === 0)" block
|
|
312
|
-
// which is technically this else block if lines.length check was outside.
|
|
313
|
-
const word = decodedWord;
|
|
314
|
-
// logic for splitting word...
|
|
315
|
-
let processedWord = '';
|
|
316
|
-
let processedWidth = 0;
|
|
317
|
-
for (const char of word) {
|
|
318
|
-
const testChunk = processedWord + char;
|
|
319
|
-
const testChunkExpanded = expandTabsToTabStops(testChunk, 8);
|
|
320
|
-
const testChunkWidth = doc.widthOfString(testChunkExpanded, props);
|
|
321
|
-
if (testChunkWidth > availableWidth) {
|
|
322
|
-
if (processedWord.length > 0 &&
|
|
323
|
-
lines.length < maxLines - 1) {
|
|
324
|
-
const chunkContent = encodeHtmlEntities(processedWord);
|
|
325
|
-
currentTokens.push({
|
|
326
|
-
type: 'text',
|
|
327
|
-
content: chunkContent,
|
|
328
|
-
decodedContent: processedWord,
|
|
329
|
-
});
|
|
330
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
331
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
332
|
-
lines.push({
|
|
333
|
-
text: result.html,
|
|
334
|
-
width: processedWidth,
|
|
335
|
-
fullWidth: processedWidth +
|
|
336
|
-
(listMeta ? listMeta.textStartPx : 0),
|
|
337
|
-
listMeta,
|
|
338
|
-
});
|
|
339
|
-
openTags = result.openTags;
|
|
340
|
-
currentTokens = [];
|
|
341
|
-
showMarkerForLine = false;
|
|
342
|
-
processedWord = char;
|
|
343
|
-
processedWidth = doc.widthOfString(expandTabsToTabStops(char, 8), props);
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
processedWord += char;
|
|
347
|
-
processedWidth = testChunkWidth;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
else {
|
|
351
|
-
processedWord += char;
|
|
352
|
-
processedWidth = testChunkWidth;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
currentLineDecoded = processedWord;
|
|
356
|
-
currentWidth = processedWidth;
|
|
357
|
-
const chunkContent = encodeHtmlEntities(processedWord);
|
|
358
|
-
currentTokens.push({
|
|
359
|
-
type: 'text',
|
|
360
|
-
content: chunkContent,
|
|
361
|
-
decodedContent: processedWord,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
// Cannot wrap anymore (max lines reached).
|
|
367
|
-
// Append to current line regardless of width.
|
|
368
|
-
if (currentLineDecoded.length > 0) {
|
|
369
|
-
currentLineDecoded = testLineDecoded;
|
|
370
|
-
currentWidth = testWidth;
|
|
371
|
-
const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
|
|
372
|
-
const decodedContent = separator.length > 0
|
|
373
|
-
? `${separator}${decodedWord}`
|
|
374
|
-
: decodedWord;
|
|
375
|
-
currentTokens.push({
|
|
376
|
-
type: 'text',
|
|
377
|
-
content: rawContent,
|
|
378
|
-
decodedContent: decodedContent,
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
382
|
-
// Edge case: Start of line, can't wrap, but word is too big.
|
|
383
|
-
// Since we can't wrap, we just force it in.
|
|
384
|
-
currentLineDecoded = decodedWord;
|
|
385
|
-
const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
|
|
386
|
-
currentWidth = doc.widthOfString(decodedWordExpanded, props);
|
|
387
|
-
currentTokens.push({
|
|
388
|
-
type: 'text',
|
|
389
|
-
content: rawWord,
|
|
390
|
-
decodedContent: decodedWord,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// Add the last line
|
|
398
|
-
if (currentLineDecoded.length > 0) {
|
|
399
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
400
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
401
|
-
lines.push({
|
|
402
|
-
text: result.html,
|
|
403
|
-
width: currentWidth,
|
|
404
|
-
fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
|
|
405
|
-
listMeta,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
else if (currentTokens.length === 0) {
|
|
409
|
-
// Handle case when paragraph becomes empty after wrapping logic
|
|
410
|
-
const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
|
|
411
|
-
lines.push({
|
|
412
|
-
text: '',
|
|
413
|
-
width: 0,
|
|
414
|
-
fullWidth: listMeta ? listMeta.textStartPx : 0,
|
|
415
|
-
listMeta,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return lines;
|
|
421
|
-
}
|
|
422
|
-
export function calculateTextMetrics(doc, element) {
|
|
423
|
-
const textOptions = {
|
|
424
|
-
align: element.align === 'justify' ? 'justify' : 'left',
|
|
425
|
-
baseline: 'alphabetic',
|
|
426
|
-
lineGap: 1,
|
|
427
|
-
width: element.width,
|
|
428
|
-
underline: element.textDecoration.indexOf('underline') >= 0,
|
|
429
|
-
characterSpacing: element.letterSpacing
|
|
430
|
-
? element.letterSpacing * element.fontSize
|
|
431
|
-
: 0,
|
|
432
|
-
lineBreak: false,
|
|
433
|
-
stroke: false,
|
|
434
|
-
fill: false,
|
|
435
|
-
};
|
|
436
|
-
const currentLineHeight = doc.heightOfString('A', textOptions);
|
|
437
|
-
const lineHeight = element.lineHeight * element.fontSize;
|
|
438
|
-
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
439
|
-
const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
|
|
440
|
-
// Calculate baseline offset based on font metrics (similar to Konva rendering)
|
|
441
|
-
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
|
|
442
|
-
lineHeight / 2;
|
|
443
|
-
// Adjust line gap to match desired line height
|
|
444
|
-
const lineHeightDiff = currentLineHeight - lineHeight;
|
|
445
|
-
textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
|
|
446
|
-
const textLines = splitTextIntoLines(doc, element, textOptions);
|
|
447
|
-
return {
|
|
448
|
-
textOptions,
|
|
449
|
-
lineHeightPx: lineHeight,
|
|
450
|
-
baselineOffset,
|
|
451
|
-
textLines,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
export function calculateVerticalAlignment(doc, element, textOptions) {
|
|
455
|
-
if (!element.verticalAlign || element.verticalAlign === 'top') {
|
|
456
|
-
return 0;
|
|
457
|
-
}
|
|
458
|
-
const strippedContent = stripHtml(element.text).result;
|
|
459
|
-
const textHeight = doc.heightOfString(strippedContent, textOptions);
|
|
460
|
-
if (element.verticalAlign === 'middle') {
|
|
461
|
-
return (element.height - textHeight) / 2;
|
|
462
|
-
}
|
|
463
|
-
else if (element.verticalAlign === 'bottom') {
|
|
464
|
-
return element.height - textHeight;
|
|
465
|
-
}
|
|
466
|
-
return 0;
|
|
467
|
-
}
|
|
468
|
-
export function fitTextToHeight(doc, element, textOptions) {
|
|
469
|
-
const strippedContent = stripHtml(element.text).result;
|
|
470
|
-
for (let size = element.fontSize; size > 0; size -= 1) {
|
|
471
|
-
doc.fontSize(size);
|
|
472
|
-
const height = doc.heightOfString(strippedContent, textOptions);
|
|
473
|
-
if (height <= element.height) {
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Calculate X offset for list markers (not used for text content positioning)
|
|
480
|
-
*/
|
|
481
|
-
export function calculateLineXOffset(element, line) {
|
|
482
|
-
// Markers are always at the left edge, regardless of text alignment
|
|
483
|
-
if (line.listMeta) {
|
|
484
|
-
return 0;
|
|
485
|
-
}
|
|
486
|
-
// For non-list lines, markers follow text alignment
|
|
487
|
-
const align = element.align;
|
|
488
|
-
const targetWidth = line.width;
|
|
489
|
-
if (align === 'right') {
|
|
490
|
-
return element.width - targetWidth;
|
|
491
|
-
}
|
|
492
|
-
else if (align === 'center') {
|
|
493
|
-
return (element.width - targetWidth) / 2;
|
|
494
|
-
}
|
|
495
|
-
// left or justify: markers at position 0
|
|
496
|
-
return 0;
|
|
497
|
-
}
|
|
498
|
-
export function calculateTextContentXOffset(element, line) {
|
|
499
|
-
const align = element.align;
|
|
500
|
-
const textWidth = line.width;
|
|
501
|
-
const baseStart = line.listMeta?.textStartPx ?? 0;
|
|
502
|
-
const availableWidth = Math.max(element.width - baseStart, 0);
|
|
503
|
-
if (align === 'right') {
|
|
504
|
-
return baseStart + Math.max(availableWidth - textWidth, 0);
|
|
505
|
-
}
|
|
506
|
-
else if (align === 'center') {
|
|
507
|
-
return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
|
|
508
|
-
}
|
|
509
|
-
return baseStart;
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Calculate effective width for text rendering, considering justify and underline constraints
|
|
513
|
-
*/
|
|
514
|
-
export function calculateEffectiveWidth(element, line, widthOption, hasUnderline) {
|
|
515
|
-
if (widthOption !== undefined) {
|
|
516
|
-
return widthOption;
|
|
517
|
-
}
|
|
518
|
-
if (hasUnderline) {
|
|
519
|
-
return element.width;
|
|
520
|
-
}
|
|
521
|
-
return undefined;
|
|
522
|
-
}
|