@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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/dist/svg.js +57 -8
  3. 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 (unreleased)
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 (unreleased)
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 ?? ""}"`, inset(box, 18), bound, {
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([quote.attribution, quote.source].filter(Boolean).join(" - "), {
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.4.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.6.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
  }