@openpresentation/opf-render 0.5.1 → 0.7.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 +5 -3
  2. package/dist/svg.js +43 -52
  3. package/package.json +7 -4
package/README.md CHANGED
@@ -1,9 +1,11 @@
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.
3
+ Version 0.7.0 requires core 0.9.0 and renders accepted quote and code geometry without fitting it again. Code filename/language/body parts preserve source whitespace, literal tabs and metadata case and expose trace targets for editing. The [43 reviewed code raster changes](docs/evidence/shared-code/raster-review.json) retain the other 762 corpus hashes. Planned coordinated releases PPTX 0.7.0 and editor 0.6.0 will add native source recovery and editing. Glyph containment and separation do not establish native pixel equivalence.
4
4
 
5
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.
6
6
 
7
+ Code strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets) reject rendering with `invalid-code-text`, the OPF field path and UTF-16 offset. Input JSON stays unchanged. Tabs, line endings and valid supplementary Unicode remain accepted; schema validity and XML serialization do not certify font coverage or native fidelity.
8
+
7
9
  ## Scope
8
10
 
9
11
  - Package: `@openpresentation/opf-render`
@@ -34,7 +36,7 @@ const pdf = await svgToPdf(svgs, { scale: 1 });
34
36
 
35
37
  `svgToPng` returns PNG bytes for one SVG. `svgToPdf` accepts one SVG or an array of SVGs and returns PDF bytes with one slide per page. The SVG page `width`/`height` or `viewBox` determines the PDF page size; `scale` controls raster density only.
36
38
 
37
- ## Browser preview and fonts (preview APIs)
39
+ ## Browser preview and fonts
38
40
 
39
41
  Use `@openpresentation/opf-render/svg` for browser rendering without Node dependencies. Browser-aware bundlers also select this shared SVG implementation for the root import; Node's root import retains PNG/PDF conversion.
40
42
 
@@ -51,7 +53,7 @@ container.innerHTML = renderSvg(presentation, {
51
53
 
52
54
  Each entry contains `url` or `data: Uint8Array`, with optional `family`, `weight`, `italic` and `license`. The loader registers browser FontFaces using the same bytes used for measurement. It fetches only URLs supplied by the host, supports an AbortSignal and custom fetch, and awaits font loading. Use pinned static faces and retain their licenses. For standalone SVG export also pass `embeddedFonts: fonts.embeddedFonts`; embedding is unnecessary for each live draft after browser fonts are loaded.
53
55
 
54
- The new entries require coordinated preview builds of OPF and the toolkit. The sibling OPF repository's `pnpm pack:ecosystem` prepares local npm tarballs; older registry releases do not include these APIs. See `opf/docs/live-editor.md` for installation and the fidelity contract. Identical SVG geometry does not guarantee identical raster pixels across browser engines or PowerPoint.
56
+ These browser entrypoints are included in the published package. For coordinated development, the sibling OPF repository's `pnpm pack:ecosystem` prepares local npm tarballs. See the core repository's [live editor guide](https://github.com/OpenPresentation/opf/blob/main/docs/live-editor.md) for installation and the fidelity contract. Identical SVG geometry does not guarantee identical raster pixels across browser engines or PowerPoint.
55
57
 
56
58
  ## Runtime Policy
57
59
 
package/dist/svg.js CHANGED
@@ -370,7 +370,9 @@ function inferLayoutId(slide) {
370
370
  if (slide.table) return "table-1x";
371
371
  if (slide.image) return "image-1x";
372
372
  if (slide.video) return "media-1x";
373
- if (slide.code) return "code-1x";
373
+ // Unspecified code layout uses the same automatic flow as core pagination
374
+ // and PPTX export. An explicit code-1x preset still reserves its own slots.
375
+ if (slide.code !== undefined) return "blank";
374
376
  if (slide.items || slide.bullets) return "list-1x";
375
377
  if (slide.text || slide.metric || slide.quote || slide.timeline) return "text-1x";
376
378
  if (slide.subtitle) return "title-subtitle";
@@ -626,7 +628,7 @@ function renderSlideContent(bound, width, height, options) {
626
628
  }
627
629
 
628
630
  function reportDiagnostic(diagnostic, options) {
629
- const key = `${diagnostic.code}:${diagnostic.path}`;
631
+ const key = `${diagnostic.code}:${diagnostic.path}:${diagnostic.reason ?? ''}`;
630
632
  if (options._diagnosticPaths.has(key)) return;
631
633
  options._diagnosticPaths.add(key);
632
634
  options.onDiagnostic?.(diagnostic);
@@ -768,8 +770,14 @@ function renderMedia(item, box, bound, options) {
768
770
  }
769
771
 
770
772
  function renderCode(item, box, bound, options) {
771
- const code = typeof item.value === "string" ? item.value : item.value?.source ?? stableJson(item.value);
772
- const label = typeof item.value === "object" && item.value?.language ? String(item.value.language) : "code";
773
+ const layout = item.codeLayout;
774
+ if (!layout) throw new OPFRenderError('missing-code-layout', 'Code rendering requires a coordinated core build with shared code geometry.', {path:item.path});
775
+ for (const part of layout.parts) {
776
+ // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
777
+ // keeps valid supplementary characters (surrogate pairs) accepted.
778
+ const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
779
+ if (invalid) throw new OPFRenderError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before rendering.`, {path:part.path});
780
+ }
773
781
  const children = [
774
782
  tag("rect", {
775
783
  x: box.x,
@@ -780,27 +788,28 @@ function renderCode(item, box, bound, options) {
780
788
  stroke: "#334155",
781
789
  "stroke-width": 1,
782
790
  ...traceAttrs(options, item.path)
783
- }),
784
- tag("text", {
785
- x: box.x + 18,
786
- y: box.y + 28,
787
- fill: "#93C5FD",
788
- "font-family": fontStack(bound.design.fonts.code, "monospace"),
789
- "font-size": 14,
790
- "font-weight": 700,
791
- ...traceAttrs(options, item.path)
792
- }, escapeText(label.toUpperCase())),
793
- renderTextBox(code, { x: box.x + 18, y: box.y + 46, width: box.width - 36, height: box.height - 64 }, bound, {
794
- path: item.path,
795
- fontSize: 18,
796
- fontFamily: bound.design.fonts.code,
797
- fontWeight: 400,
798
- fill: "#E5E7EB",
799
- options,
800
- preserveNewlines: true
801
791
  })
802
792
  ];
803
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
793
+ for (const part of layout.parts) {
794
+ if (!part.fit) throw new OPFRenderError('layout-overflow', 'Code content has no usable internal space; increase its cell size before rendering.', {path:part.path,issues:layout.diagnostics});
795
+ const lines=part.fit.sourceLines.map((line,index)=>tag('text',{
796
+ x:stableNumber(part.box.x),y:stableNumber(part.box.y+part.fit.fontSize+index*part.fit.lineHeight),
797
+ 'text-anchor':'start','font-family':fontStack(part.style.fontFamily,'monospace'),
798
+ 'font-size':stableNumber(part.fit.fontSize),'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
799
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
800
+ ...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
801
+ 'data-opf-text-start':line.start,'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
802
+ },line.segments.map(segment=>tag('tspan',{
803
+ x:stableNumber(part.box.x+segment.x),
804
+ // SVG's CSS tab-size does not place literal tabs at their measured stops.
805
+ ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
806
+ ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
807
+ },escapeText(part.text.slice(segment.start,segment.end)))).join('')));
808
+ children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
809
+ 'data-opf-box-x':part.box.x,'data-opf-box-y':part.box.y,'data-opf-box-width':part.box.width,'data-opf-box-height':part.box.height}:{}),
810
+ ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
811
+ }
812
+ return tag("g", {...traceAttrs(options,item.path),...(options.trace?{'data-opf-code-container':'true'}:{})}, children.join("\n"));
804
813
  }
805
814
 
806
815
  function renderMetric(item, box, bound, options) {
@@ -832,34 +841,16 @@ function renderMetric(item, box, bound, options) {
832
841
  }
833
842
 
834
843
  function renderQuote(item, box, bound, options) {
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);
840
- const children = [
841
- renderTextBox(`"${quote.text ?? ""}"`, body, bound, {
842
- path: `${item.path}.text`,
843
- fontSize: 28,
844
- fontFamily: bound.design.fonts.heading,
845
- fontWeight: 600,
846
- fill: bound.design.colors.text,
847
- options
848
- }),
849
- renderTextBox(attribution, {
850
- x: box.x + 18,
851
- y: box.y + box.height - 58,
852
- width: box.width - 36,
853
- height: 40
854
- }, bound, {
855
- path: item.path,
856
- fontSize: 17,
857
- fontFamily: bound.design.fonts.body,
858
- fontWeight: 500,
859
- fill: bound.design.colors.mutedText,
860
- options
861
- })
862
- ];
844
+ const layout = item.quoteLayout;
845
+ if (!layout) throw new OPFRenderError('missing-quote-layout', 'Quote rendering requires a coordinated core build with shared quote geometry.', {path:item.path});
846
+ const children = layout.parts.map(part => {
847
+ if (!part.fit) throw new OPFRenderError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before rendering.', {path:part.path,issues:layout.diagnostics});
848
+ return renderTextBox(part.text,part.box,bound,{
849
+ path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,
850
+ fill:part.role==='footer'?bound.design.colors.mutedText:bound.design.colors.text,
851
+ diagnosticsHandled:true,options,
852
+ });
853
+ });
863
854
  return tag("g", traceAttrs(options, item.path), children.join("\n"));
864
855
  }
865
856
 
@@ -1223,7 +1214,7 @@ function renderTextBox(text, box, bound, config) {
1223
1214
  const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
1224
1215
  const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
1225
1216
  ((bound.composition ?? bound.geometry.composition).minFontSize ?? 16) * scale, textWidthMeasurer(style,config.options.textMeasurement));
1226
- if (fit.overflow) {
1217
+ if (fit.overflow && !config.diagnosticsHandled) {
1227
1218
  const diagnostic = { code: "text-overflow", path: config.path,
1228
1219
  message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." };
1229
1220
  reportDiagnostic(diagnostic, config.options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,13 +57,16 @@
57
57
  "build": "node scripts/build.mjs",
58
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/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",
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/shared-quote.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:packed": "node test/packed-install.mjs",
62
63
  "test:browser": "npm run build:browser-check && node test/browser-check.mjs",
63
64
  "golden:update": "npm run build && node test/golden.mjs --update",
64
65
  "prepack": "npm run build",
65
66
  "build:browser-check": "node scripts/build-browser-check.mjs",
66
- "test:styled-table": "node test/styled-table.mjs"
67
+ "test:styled-table": "node test/styled-table.mjs",
68
+ "test:code": "node test/shared-code.mjs",
69
+ "test:code-browser": "node test/shared-code-browser.mjs"
67
70
  },
68
71
  "dependencies": {
69
72
  "@expo-google-fonts/arimo": "0.4.3",
@@ -74,7 +77,7 @@
74
77
  "@expo-google-fonts/roboto": "^0.4.3",
75
78
  "@expo-google-fonts/roboto-mono": "^0.4.2",
76
79
  "@expo-google-fonts/tinos": "0.4.2",
77
- "@openpresentation/opf": "^0.7.0",
80
+ "@openpresentation/opf": "^0.9.0",
78
81
  "@resvg/resvg-js": "^2.6.2",
79
82
  "fontkit": "^2.0.4",
80
83
  "pdf-lib": "^1.17.1",