@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.
Files changed (3) hide show
  1. package/README.md +14 -2
  2. package/dist/svg.js +64 -45
  3. 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 (unreleased)
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 (unreleased)
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 table = item.value ?? {};
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
- allRows.forEach((row, rowIndex) => {
925
- const cells = Array.isArray(row) ? row : [row];
926
- for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
927
- const x = box.x + columnIndex * cellWidth;
928
- const y = box.y + rowIndex * cellHeight;
929
- const cellPath = rowIndex === 0 && columns.length ? `${item.path}.columns.${columnIndex}` : `${item.path}.rows.${rowIndex - (columns.length ? 1 : 0)}.${columnIndex}`;
930
- children.push(tag("rect", {
931
- x: stableNumber(x),
932
- y: stableNumber(y),
933
- width: stableNumber(cellWidth),
934
- height: stableNumber(cellHeight),
935
- fill: rowIndex === 0 && columns.length ? bound.design.colors.primary : bound.design.colors.surface,
936
- stroke: bound.design.colors.border,
937
- "stroke-width": 1,
938
- ...traceAttrs(options, cellPath)
939
- }));
940
- const rich = Array.isArray(cells[columnIndex]);
941
- children.push((rich ? renderRichTextBox : renderTextBox)(rich ? cells[columnIndex] : flattenText(cells[columnIndex] ?? ""), {
942
- x: x + 10 * scale,
943
- y: y + 8 * scale,
944
- width: Math.max(scale, cellWidth - 20 * scale),
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.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.5.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",