@openpresentation/opf-render 0.4.0 → 0.5.1
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 +16 -2
- package/dist/svg.js +57 -8
- package/package.json +9 -6
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# OPF Render
|
|
2
2
|
|
|
3
|
+
Version 0.5.1 reserves quote attribution/source space before fitting long text. See [quote layout and real-browser evidence](docs/quote-footer-0.5.1.md) for regression coverage and overflow behavior.
|
|
4
|
+
|
|
3
5
|
Deterministic local renderer for Open Presentation Format documents. The shared SVG core implements validation, catalog resolution, placeholder binding and text layout. Node APIs additionally convert SVG to PNG and raster-backed PDF.
|
|
4
6
|
|
|
5
7
|
## Scope
|
|
@@ -114,7 +116,7 @@ Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; th
|
|
|
114
116
|
|
|
115
117
|
Version 0.1.1 adds rich-text line offsets and measured boxes to opt-in SVG tracing, including blank lines, for editor caret placement. Normal SVG/PNG output is unchanged.
|
|
116
118
|
|
|
117
|
-
## Embedded WebP in PNG/PDF output (
|
|
119
|
+
## Embedded WebP in PNG/PDF output (since 0.2.0)
|
|
118
120
|
|
|
119
121
|
PNG and PDF conversion now decode embedded WebP image data URIs to static PNG before rasterization. This preserves fit/crop, transparency, EXIF orientation and the first animation frame. The source SVG and OPF document remain unchanged; browser SVG output keeps the original embedded image. Both href and legacy xlink:href are supported, including base64 and percent-encoded data. This step does not resolve local filenames, download remote images or change the existing host imageResolver contract.
|
|
120
122
|
|
|
@@ -122,7 +124,7 @@ The Node-only raster path lazily loads pinned Sharp 0.35.4, requiring Node 20.9
|
|
|
122
124
|
|
|
123
125
|
Regression checks compare six fixtures against independent Pillow pixels, verify fit/crop, inspect actual PDF image streams and transparency masks, and exercise input encodings and diagnostics. All 805 existing raster baselines remain unchanged. Test fixtures are project-authored and MIT licensed; the decoder and its bundled codecs retain their upstream licenses.
|
|
124
126
|
|
|
125
|
-
## JPEG EXIF orientation in raster output (
|
|
127
|
+
## JPEG EXIF orientation in raster output (since 0.2.0)
|
|
126
128
|
|
|
127
129
|
PNG/PDF raster preparation now honors all eight JPEG EXIF orientations. JPEGs with orientation 2–8 are decoded to oriented PNG pixels for rasterization; JPEGs with orientation 1 or no orientation keep their original SVG attribute spelling and compressed bytes. This does not modify the source document or the browser SVG output. The Node path reads JPEG metadata through the same lazy Sharp dependency used for WebP.
|
|
128
130
|
|
|
@@ -131,3 +133,15 @@ Twenty-four cases verify fit/crop, PNG pixels and actual PDF image streams again
|
|
|
131
133
|
`npm test` also builds the browser comparison and rejects native raster modules in browser output. Serve this repository and open `/artifacts/jpeg/browser/index.html` to run it. The harness permits mean channel error up to 1 and at most 2% of channels differing by more than 10; a deliberately unrotated image must fail that criterion.
|
|
132
134
|
|
|
133
135
|
Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
|
|
136
|
+
|
|
137
|
+
## Styled and merged table cells
|
|
138
|
+
|
|
139
|
+
Renderer 0.5.0 requires core 0.7.0 and uses its shared styled-cell geometry. A cell may wrap a scalar or rich value with styles and spans:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{"rows":[[{"value":"Merged heading","colSpan":2,"style":{"fill":"#DBEAFE","align":"center","padding":{"top":12},"borders":{"bottom":{"color":"#445566","width":2,"dash":"dot"}}}},null],["Left","Right"]]}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Every covered position is explicit `null`. Each merged anchor renders once; traced text uses its `.value` path for editing. Styles support RGB/RGBA fills and text colors, horizontal and vertical alignment, reference-pixel padding and per-edge solid/dashed/dotted borders. Rich-run colors override the cell text color. Columns remain equal width; rows grow from measured text, padding and span constraints. Pagination preserves connected vertical merge groups.
|
|
146
|
+
|
|
147
|
+
The Node20/24 renderer suite retains all 805 existing raster baselines. Browser fixtures verify merged text containment and styled-cell typing, formatting, cancellation and undo with actual loaded fonts. Native PowerPoint appearance remains a separate converter verification boundary.
|
package/dist/svg.js
CHANGED
|
@@ -833,8 +833,12 @@ function renderMetric(item, box, bound, options) {
|
|
|
833
833
|
|
|
834
834
|
function renderQuote(item, box, bound, options) {
|
|
835
835
|
const quote = isPlainObject(item.value) ? item.value : { text: item.value };
|
|
836
|
+
const attribution = [quote.attribution, quote.source].filter(Boolean).join(" - ");
|
|
837
|
+
const body = inset(box, 18);
|
|
838
|
+
// Reserve the attribution footer before fitting the quote; it is not an overlay.
|
|
839
|
+
if (attribution) body.height = Math.max(1, box.height - 94);
|
|
836
840
|
const children = [
|
|
837
|
-
renderTextBox(`"${quote.text ?? ""}"`,
|
|
841
|
+
renderTextBox(`"${quote.text ?? ""}"`, body, bound, {
|
|
838
842
|
path: `${item.path}.text`,
|
|
839
843
|
fontSize: 28,
|
|
840
844
|
fontFamily: bound.design.fonts.heading,
|
|
@@ -842,7 +846,7 @@ function renderQuote(item, box, bound, options) {
|
|
|
842
846
|
fill: bound.design.colors.text,
|
|
843
847
|
options
|
|
844
848
|
}),
|
|
845
|
-
renderTextBox(
|
|
849
|
+
renderTextBox(attribution, {
|
|
846
850
|
x: box.x + 18,
|
|
847
851
|
y: box.y + box.height - 58,
|
|
848
852
|
width: box.width - 36,
|
|
@@ -913,22 +917,68 @@ function renderTable(item, box, bound, options) {
|
|
|
913
917
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
914
918
|
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path});
|
|
915
919
|
const children = [];
|
|
920
|
+
const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
|
|
921
|
+
const defaultEdges = [], explicitEdges = [];
|
|
916
922
|
for (const row of layout.rows) for (const cell of row.cells) {
|
|
923
|
+
const style = cell.style ?? {};
|
|
917
924
|
children.push(tag("rect", {
|
|
918
925
|
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
919
926
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
920
|
-
fill: cell.header ? bound.design.colors.primary : bound.design.colors.surface,
|
|
921
|
-
stroke: bound.design.colors.border, "stroke-width":1,
|
|
922
|
-
...traceAttrs(options, cell.path)
|
|
927
|
+
fill: style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface),
|
|
928
|
+
stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
|
|
929
|
+
...traceAttrs(options, cell.sourcePath ?? cell.path)
|
|
923
930
|
}));
|
|
931
|
+
if (separateBorders) {
|
|
932
|
+
const {x, y, width, height} = cell.box;
|
|
933
|
+
const edges = {top:[x,y,x+width,y],right:[x+width,y,x+width,y+height],bottom:[x,y+height,x+width,y+height],left:[x,y,x,y+height]};
|
|
934
|
+
for (const [edge, coordinates] of Object.entries(edges)) {
|
|
935
|
+
const border = style.borders?.[edge];
|
|
936
|
+
(border ? explicitEdges : defaultEdges).push({coordinates, border, path:`${cell.sourcePath ?? cell.path}.style.borders.${edge}`});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// Core layout has already applied vertical alignment to cell.textBox.y.
|
|
924
940
|
children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
|
|
925
941
|
path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
|
|
926
942
|
fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
|
|
927
|
-
fill:cell.header ? "#FFFFFF" : bound.design.colors.text, options
|
|
943
|
+
fill:style.color ?? (cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
|
|
928
944
|
}));
|
|
929
945
|
}
|
|
930
946
|
|
|
931
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
947
|
+
return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound.design.colors.border, options)].join("\n"));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Explicit edges own their shared segment, including invisible/zero-width edges.
|
|
951
|
+
// Split implicit neighbors at merge boundaries so they cannot fill dashed gaps,
|
|
952
|
+
// cover alpha strokes, or reintroduce a border the author removed.
|
|
953
|
+
function renderTableBorders(defaultEdges, explicitEdges, scale, defaultColor, options) {
|
|
954
|
+
const epsilon = 1e-7;
|
|
955
|
+
const segment = ({coordinates:[x1,y1,x2,y2]}) => y1 === y2
|
|
956
|
+
? {horizontal:true, fixed:y1, start:x1, end:x2}
|
|
957
|
+
: {horizontal:false, fixed:x1, start:y1, end:y2};
|
|
958
|
+
const blockers = explicitEdges.map(segment);
|
|
959
|
+
const defaults = defaultEdges.flatMap(edge => {
|
|
960
|
+
const axis = segment(edge);
|
|
961
|
+
let intervals = [[axis.start, axis.end]];
|
|
962
|
+
for (const blocker of blockers) {
|
|
963
|
+
if (axis.horizontal !== blocker.horizontal || Math.abs(axis.fixed - blocker.fixed) > epsilon) continue;
|
|
964
|
+
intervals = intervals.flatMap(([start,end]) => {
|
|
965
|
+
if (blocker.end <= start + epsilon || blocker.start >= end - epsilon) return [[start,end]];
|
|
966
|
+
return [[start,Math.min(end,blocker.start)],[Math.max(start,blocker.end),end]].filter(([a,b]) => b-a > epsilon);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return intervals.map(([start,end]) => ({...edge,coordinates:axis.horizontal
|
|
970
|
+
? [start,axis.fixed,end,axis.fixed] : [axis.fixed,start,axis.fixed,end]}));
|
|
971
|
+
});
|
|
972
|
+
return [...defaults,...explicitEdges].flatMap(({coordinates:[x1,y1,x2,y2],border,path}) => {
|
|
973
|
+
const width = border ? border.width * scale : 1;
|
|
974
|
+
if (width === 0) return [];
|
|
975
|
+
return [tag('line', {
|
|
976
|
+
x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
|
|
977
|
+
stroke:border?.color ?? defaultColor, 'stroke-width':stableNumber(width),
|
|
978
|
+
'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
|
|
979
|
+
...traceAttrs(options,path)
|
|
980
|
+
})];
|
|
981
|
+
});
|
|
932
982
|
}
|
|
933
983
|
|
|
934
984
|
function renderImportedChart(item, box, bound, options) {
|
|
@@ -1280,4 +1330,3 @@ function tag(name, attrs = {}, children = "") {
|
|
|
1280
1330
|
if (children === "") return `<${name}${serializedAttrs}/>`;
|
|
1281
1331
|
return `<${name}${serializedAttrs}>${children}</${name}>`;
|
|
1282
1332
|
}
|
|
1283
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,13 +55,15 @@
|
|
|
55
55
|
"sideEffects": false,
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "node scripts/build.mjs",
|
|
58
|
-
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs",
|
|
58
|
+
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/styled-table.mjs",
|
|
59
59
|
"validate": "node scripts/validate-package.mjs",
|
|
60
|
-
"test": "npm run build && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/timeline.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/design-preview.mjs && npm run build:browser-check",
|
|
60
|
+
"test": "npm run build && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/timeline.mjs && node test/quote-footer.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/design-preview.mjs && npm run build:browser-check && npm run test:styled-table",
|
|
61
61
|
"test:golden": "npm run build && node test/golden.mjs",
|
|
62
|
+
"test:browser": "npm run build:browser-check && node test/browser-check.mjs",
|
|
62
63
|
"golden:update": "npm run build && node test/golden.mjs --update",
|
|
63
64
|
"prepack": "npm run build",
|
|
64
|
-
"build:browser-check": "node scripts/build-browser-check.mjs"
|
|
65
|
+
"build:browser-check": "node scripts/build-browser-check.mjs",
|
|
66
|
+
"test:styled-table": "node test/styled-table.mjs"
|
|
65
67
|
},
|
|
66
68
|
"dependencies": {
|
|
67
69
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
@@ -72,13 +74,14 @@
|
|
|
72
74
|
"@expo-google-fonts/roboto": "^0.4.3",
|
|
73
75
|
"@expo-google-fonts/roboto-mono": "^0.4.2",
|
|
74
76
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
75
|
-
"@openpresentation/opf": "^0.
|
|
77
|
+
"@openpresentation/opf": "^0.7.0",
|
|
76
78
|
"@resvg/resvg-js": "^2.6.2",
|
|
77
79
|
"fontkit": "^2.0.4",
|
|
78
80
|
"pdf-lib": "^1.17.1",
|
|
79
81
|
"sharp": "0.35.4"
|
|
80
82
|
},
|
|
81
83
|
"devDependencies": {
|
|
82
|
-
"esbuild": "0.28.2"
|
|
84
|
+
"esbuild": "0.28.2",
|
|
85
|
+
"playwright": "1.63.0"
|
|
83
86
|
}
|
|
84
87
|
}
|