@openpresentation/opf-render 0.3.0 → 0.5.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/README.md +14 -2
- package/dist/svg.js +64 -45
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ Raster updates produce an HTML gallery and `artifacts/golden/candidate.json`; th
|
|
|
114
114
|
|
|
115
115
|
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
116
|
|
|
117
|
-
## Embedded WebP in PNG/PDF output (
|
|
117
|
+
## Embedded WebP in PNG/PDF output (since 0.2.0)
|
|
118
118
|
|
|
119
119
|
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
120
|
|
|
@@ -122,7 +122,7 @@ The Node-only raster path lazily loads pinned Sharp 0.35.4, requiring Node 20.9
|
|
|
122
122
|
|
|
123
123
|
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
124
|
|
|
125
|
-
## JPEG EXIF orientation in raster output (
|
|
125
|
+
## JPEG EXIF orientation in raster output (since 0.2.0)
|
|
126
126
|
|
|
127
127
|
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
128
|
|
|
@@ -131,3 +131,15 @@ Twenty-four cases verify fit/crop, PNG pixels and actual PDF image streams again
|
|
|
131
131
|
`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
132
|
|
|
133
133
|
Version 0.2.0 requires Node 20.9 or later for native image decoding. Browser bundles continue using browser-safe entrypoints.
|
|
134
|
+
|
|
135
|
+
## Styled and merged table cells
|
|
136
|
+
|
|
137
|
+
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:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{"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"]]}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText } from "@openpresentation/opf/composition";
|
|
1
|
+
import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText } from "@openpresentation/opf/composition";
|
|
2
2
|
import {
|
|
3
3
|
catalogs as bundledCatalogs,
|
|
4
4
|
validatePresentation
|
|
@@ -911,50 +911,70 @@ function renderTimeline(item, box, bound, options) {
|
|
|
911
911
|
|
|
912
912
|
function renderTable(item, box, bound, options) {
|
|
913
913
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
914
|
-
const
|
|
915
|
-
const columns = Array.isArray(table.columns) ? table.columns : [];
|
|
916
|
-
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
917
|
-
const allRows = columns.length ? [columns, ...rows] : rows;
|
|
918
|
-
const columnCount = Math.max(1, ...allRows.map((row) => Array.isArray(row) ? row.length : 1));
|
|
919
|
-
const rowCount = Math.max(1, allRows.length);
|
|
920
|
-
const cellWidth = box.width / columnCount;
|
|
921
|
-
const cellHeight = Math.min(54 * scale, box.height / rowCount);
|
|
914
|
+
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});
|
|
922
915
|
const children = [];
|
|
916
|
+
const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
|
|
917
|
+
const defaultEdges = [], explicitEdges = [];
|
|
918
|
+
for (const row of layout.rows) for (const cell of row.cells) {
|
|
919
|
+
const style = cell.style ?? {};
|
|
920
|
+
children.push(tag("rect", {
|
|
921
|
+
x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
|
|
922
|
+
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
923
|
+
fill: style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface),
|
|
924
|
+
stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
|
|
925
|
+
...traceAttrs(options, cell.sourcePath ?? cell.path)
|
|
926
|
+
}));
|
|
927
|
+
if (separateBorders) {
|
|
928
|
+
const {x, y, width, height} = cell.box;
|
|
929
|
+
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]};
|
|
930
|
+
for (const [edge, coordinates] of Object.entries(edges)) {
|
|
931
|
+
const border = style.borders?.[edge];
|
|
932
|
+
(border ? explicitEdges : defaultEdges).push({coordinates, border, path:`${cell.sourcePath ?? cell.path}.style.borders.${edge}`});
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
// Core layout has already applied vertical alignment to cell.textBox.y.
|
|
936
|
+
children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
|
|
937
|
+
path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
|
|
938
|
+
fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
|
|
939
|
+
fill:style.color ?? (cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
|
|
940
|
+
}));
|
|
941
|
+
}
|
|
923
942
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
height: Math.max(scale, cellHeight - 12 * scale)
|
|
946
|
-
}, bound, {
|
|
947
|
-
path: cellPath,
|
|
948
|
-
fontSize: 15,
|
|
949
|
-
fontFamily: bound.design.fonts.body,
|
|
950
|
-
fontWeight: rowIndex === 0 && columns.length ? 700 : 400,
|
|
951
|
-
fill: rowIndex === 0 && columns.length ? "#FFFFFF" : bound.design.colors.text,
|
|
952
|
-
options
|
|
953
|
-
}));
|
|
943
|
+
return tag("g", traceAttrs(options, item.path), [...children, ...renderTableBorders(defaultEdges, explicitEdges, scale, bound.design.colors.border, options)].join("\n"));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Explicit edges own their shared segment, including invisible/zero-width edges.
|
|
947
|
+
// Split implicit neighbors at merge boundaries so they cannot fill dashed gaps,
|
|
948
|
+
// cover alpha strokes, or reintroduce a border the author removed.
|
|
949
|
+
function renderTableBorders(defaultEdges, explicitEdges, scale, defaultColor, options) {
|
|
950
|
+
const epsilon = 1e-7;
|
|
951
|
+
const segment = ({coordinates:[x1,y1,x2,y2]}) => y1 === y2
|
|
952
|
+
? {horizontal:true, fixed:y1, start:x1, end:x2}
|
|
953
|
+
: {horizontal:false, fixed:x1, start:y1, end:y2};
|
|
954
|
+
const blockers = explicitEdges.map(segment);
|
|
955
|
+
const defaults = defaultEdges.flatMap(edge => {
|
|
956
|
+
const axis = segment(edge);
|
|
957
|
+
let intervals = [[axis.start, axis.end]];
|
|
958
|
+
for (const blocker of blockers) {
|
|
959
|
+
if (axis.horizontal !== blocker.horizontal || Math.abs(axis.fixed - blocker.fixed) > epsilon) continue;
|
|
960
|
+
intervals = intervals.flatMap(([start,end]) => {
|
|
961
|
+
if (blocker.end <= start + epsilon || blocker.start >= end - epsilon) return [[start,end]];
|
|
962
|
+
return [[start,Math.min(end,blocker.start)],[Math.max(start,blocker.end),end]].filter(([a,b]) => b-a > epsilon);
|
|
963
|
+
});
|
|
954
964
|
}
|
|
965
|
+
return intervals.map(([start,end]) => ({...edge,coordinates:axis.horizontal
|
|
966
|
+
? [start,axis.fixed,end,axis.fixed] : [axis.fixed,start,axis.fixed,end]}));
|
|
967
|
+
});
|
|
968
|
+
return [...defaults,...explicitEdges].flatMap(({coordinates:[x1,y1,x2,y2],border,path}) => {
|
|
969
|
+
const width = border ? border.width * scale : 1;
|
|
970
|
+
if (width === 0) return [];
|
|
971
|
+
return [tag('line', {
|
|
972
|
+
x1:stableNumber(x1), y1:stableNumber(y1), x2:stableNumber(x2), y2:stableNumber(y2),
|
|
973
|
+
stroke:border?.color ?? defaultColor, 'stroke-width':stableNumber(width),
|
|
974
|
+
'stroke-dasharray':border?.dash === 'dash' ? `${width*4} ${width*3}` : border?.dash === 'dot' ? `${width} ${width*2}` : undefined,
|
|
975
|
+
...traceAttrs(options,path)
|
|
976
|
+
})];
|
|
955
977
|
});
|
|
956
|
-
|
|
957
|
-
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
958
978
|
}
|
|
959
979
|
|
|
960
980
|
function renderImportedChart(item, box, bound, options) {
|
|
@@ -1168,7 +1188,7 @@ function renderEmbeddedFonts(fonts = []) {
|
|
|
1168
1188
|
|
|
1169
1189
|
function renderRichTextBox(value, box, bound, config) {
|
|
1170
1190
|
const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
|
|
1171
|
-
const fit=fitRichText(value,box,config.fontSize*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:config.fontFamily,fontWeight:config.fontWeight??400,path:config.path},textMeasurement:config.options.textMeasurement});
|
|
1191
|
+
const fit=config.fit??fitRichText(value,box,config.fontSize*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,{style:{fontFamily:config.fontFamily,fontWeight:config.fontWeight??400,path:config.path},textMeasurement:config.options.textMeasurement});
|
|
1172
1192
|
if(fit.overflow){const diagnostic={code:'text-overflow',path:config.path,message:'Mixed-style text exceeds its cell at the minimum font size.'};reportDiagnostic(diagnostic,config.options);if((bound.composition??bound.geometry.composition).overflow==='error')throw new OPFRenderError('layout-overflow',diagnostic.message,{issues:[diagnostic]});}
|
|
1173
1193
|
return renderRichLines(value,fit,box,bound,config);
|
|
1174
1194
|
}
|
|
@@ -1196,8 +1216,8 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1196
1216
|
|
|
1197
1217
|
function renderTextBox(text, box, bound, config) {
|
|
1198
1218
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1199
|
-
const style = resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
|
|
1200
|
-
const fit = fitText(String(text ?? ""), box, config.fontSize * scale,
|
|
1219
|
+
const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
|
|
1220
|
+
const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
|
|
1201
1221
|
((bound.composition ?? bound.geometry.composition).minFontSize ?? 16) * scale, textWidthMeasurer(style,config.options.textMeasurement));
|
|
1202
1222
|
if (fit.overflow) {
|
|
1203
1223
|
const diagnostic = { code: "text-overflow", path: config.path,
|
|
@@ -1306,4 +1326,3 @@ function tag(name, attrs = {}, children = "") {
|
|
|
1306
1326
|
if (children === "") return `<${name}${serializedAttrs}/>`;
|
|
1307
1327
|
return `<${name}${serializedAttrs}>${children}</${name}>`;
|
|
1308
1328
|
}
|
|
1309
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -55,13 +55,14 @@
|
|
|
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/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
62
|
"golden:update": "npm run build && node test/golden.mjs --update",
|
|
63
63
|
"prepack": "npm run build",
|
|
64
|
-
"build:browser-check": "node scripts/build-browser-check.mjs"
|
|
64
|
+
"build:browser-check": "node scripts/build-browser-check.mjs",
|
|
65
|
+
"test:styled-table": "node test/styled-table.mjs"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
68
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
"@expo-google-fonts/roboto": "^0.4.3",
|
|
73
74
|
"@expo-google-fonts/roboto-mono": "^0.4.2",
|
|
74
75
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
75
|
-
"@openpresentation/opf": "^0.
|
|
76
|
+
"@openpresentation/opf": "^0.7.0",
|
|
76
77
|
"@resvg/resvg-js": "^2.6.2",
|
|
77
78
|
"fontkit": "^2.0.4",
|
|
78
79
|
"pdf-lib": "^1.17.1",
|