@openpresentation/opf-render 0.6.0 → 0.8.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/dist/svg.js CHANGED
@@ -1,4 +1,4 @@
1
- import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText } from "@openpresentation/opf/composition";
1
+ import { layoutTable, fitList, fitRichText, composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, fitText, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
2
2
  import {
3
3
  catalogs as bundledCatalogs,
4
4
  validatePresentation
@@ -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";
@@ -483,7 +485,7 @@ function bindSlide(presentation, slide, layout, index, context) {
483
485
 
484
486
  const design = resolveDesign(presentation, slide, context);
485
487
  for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},context.options.textMeasurement).fontFamily;
486
- const geometry = composeSlide(slide, { ...design.dimensions, layout, slideIndex: index, fonts: design.fonts, textMeasurement: context.options.textMeasurement });
488
+ const geometry = composeSlide(slide, { ...design.dimensions, layout, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, textMeasurement: context.options.textMeasurement });
487
489
  return {
488
490
  geometry,
489
491
  assets: presentation.assets ?? {},
@@ -620,7 +622,8 @@ function renderBackground(bound, width, height, options) {
620
622
  function renderSlideContent(bound, width, height, options) {
621
623
  for (const diagnostic of bound.geometry.diagnostics) reportDiagnostic(diagnostic, options);
622
624
  return bound.geometry.items.map(item => {
623
- const surface=bound.design.contentBox && !['title','subtitle','tag'].includes(item.field) ? tag('rect',{x:item.box.x,y:item.box.y,width:item.box.width,height:item.box.height,rx:8,fill:bound.design.colors.surface,stroke:bound.design.colors.border}) : '';
625
+ const frame=item.frameBox;
626
+ const surface=frame ? tag('rect',{x:frame.x,y:frame.y,width:frame.width,height:frame.height,rx:8*Math.min(width,height)/720,fill:bound.design.colors.surface,stroke:bound.design.colors.border}) : '';
624
627
  return surface+renderPayload(item, item.box, { ...bound, composition: item.composition }, options);
625
628
  });
626
629
  }
@@ -667,6 +670,9 @@ function renderTextPayload(item, box, bound, options) {
667
670
  fontFamily: item.field === "title" ? bound.design.fonts.heading : bound.design.fonts.body,
668
671
  fontWeight: item.field === "title" ? 700 : 400,
669
672
  fill: bound.design.colors.text,
673
+ fit: item.text,
674
+ textStyle: item.textStyle,
675
+ diagnosticsHandled: Boolean(item.text),
670
676
  options
671
677
  });
672
678
  }
@@ -687,11 +693,14 @@ function renderList(item, box, bound, options) {
687
693
  function renderImage(item, box, bound, options) {
688
694
  let asset = normalizeAsset(item.value);
689
695
  const seen = new Set();
696
+ let missingReference;
690
697
  while (asset.src?.startsWith("asset:")) {
691
698
  const id = asset.src.slice(6);
692
699
  if (seen.has(id)) throw new OPFRenderError("invalid-asset-reference", "Circular asset reference.", { path: item.path });
693
700
  seen.add(id);
694
- asset = normalizeAsset(bound.assets[id]);
701
+ if (!Object.hasOwn(bound.assets,id)) { missingReference=id; break; }
702
+ const {src:_source,...overrides}=asset;
703
+ asset = {...normalizeAsset(bound.assets[id]),...overrides};
695
704
  }
696
705
  const source = options.imageResolver?.(asset.src, { asset, path: item.path }) ?? asset.src;
697
706
  if (typeof source === "string" && /^data:image\/(png|jpeg|gif|webp);base64,/i.test(source)) {
@@ -699,8 +708,18 @@ function renderImage(item, box, bound, options) {
699
708
  href: source, preserveAspectRatio: (options.imageFit ?? (bound.design.imageFill === "crop" ? "cover" : "contain")) === "cover" ? "xMidYMid slice" : "xMidYMid meet", role: "img", "aria-label": asset.alt ?? "Image",
700
709
  ...traceAttrs(options, item.path) });
701
710
  }
702
- if (options.strictAssets) throw new OPFRenderError("unresolved-asset", "Image requires an embedded raster data URI or a host imageResolver.", { path: item.path });
711
+ const reason=missingReference?'missing-reference':!asset.src?'missing-source':'unsupported-source';
712
+ const message=missingReference?`Image asset ${missingReference} is missing; supply the referenced asset or resolve it in the host.`:'Image requires an embedded raster data URI or a host imageResolver.';
713
+ if (options.strictAssets) throw new OPFRenderError("unresolved-asset", message, { path: item.path,reason });
703
714
  const fill = bound.design.colors.surface;
715
+ const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
716
+ const padding=Math.min(24*scale,box.width*.06,box.height*.1),inner=inset(box,padding);
717
+ const description=asset.alt??asset.title??'Image';
718
+ const label=`Image unavailable\n${description}`;
719
+ const style=resolveTextStyle({fontFamily:bound.design.fonts.body,fontWeight:600,path:item.path},options.textMeasurement);
720
+ const fit=fitText(label,inner,20*scale,((bound.composition??bound.geometry.composition).minFontSize??16)*scale,textWidthMeasurer(style,options.textMeasurement));
721
+ const textColor=textColorForFill(fill,bound.design.colors.text);
722
+ reportDiagnostic({code:'unresolved-asset',path:item.path,message,reason,source:asset.src,assetId:missingReference,description,placeholder:fit.overflow?'icon':'label'},options);
704
723
  const children = [
705
724
  tag("rect", {
706
725
  x: box.x,
@@ -711,21 +730,28 @@ function renderImage(item, box, bound, options) {
711
730
  fill,
712
731
  stroke: bound.design.colors.border,
713
732
  "stroke-width": 1,
733
+ "stroke-dasharray": "4 3",
714
734
  ...traceAttrs(options, item.path)
715
- }),
716
- renderTextBox(asset.alt || asset.title || asset.src || "Image", inset(box, 24), bound, {
735
+ })
736
+ ];
737
+ if(!fit.overflow)children.push(renderTextBox(label, inner, bound, {
717
738
  path: item.path,
718
739
  fontSize: 20,
719
- fontFamily: bound.design.fonts.body,
740
+ fontFamily: style.fontFamily,
720
741
  fontWeight: 600,
721
- fill: bound.design.colors.mutedText,
742
+ textStyle:style,fit,diagnosticsHandled:true,
743
+ fill: textColor,
722
744
  options,
723
745
  align: "center",
724
746
  verticalAlign: "middle"
725
- })
726
- ];
727
-
728
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
747
+ }));
748
+ else {
749
+ // This is a status indicator, not shortened authored content. The complete
750
+ // description remains in the accessible name and the path-specific diagnostic.
751
+ const size=Math.max(0,Math.min(inner.width,inner.height,24*scale)),icon=centeredBox(inner,size,size);
752
+ children.push(tag('path',{d:`M ${icon.x} ${icon.y} L ${icon.x+size} ${icon.y+size} M ${icon.x+size} ${icon.y} L ${icon.x} ${icon.y+size}`,fill:'none',stroke:textColor,'stroke-width':Math.min(2*scale,size/8),'aria-hidden':'true'}));
753
+ }
754
+ return tag("g", {...traceAttrs(options,item.path),'data-opf-asset-status':'unresolved',role:'img','aria-label':`Image unavailable: ${description}`},children.join("\n"));
729
755
  }
730
756
 
731
757
  function renderMedia(item, box, bound, options) {
@@ -768,8 +794,14 @@ function renderMedia(item, box, bound, options) {
768
794
  }
769
795
 
770
796
  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";
797
+ const layout = item.codeLayout;
798
+ if (!layout) throw new OPFRenderError('missing-code-layout', 'Code rendering requires a coordinated core build with shared code geometry.', {path:item.path});
799
+ for (const part of layout.parts) {
800
+ // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
801
+ // keeps valid supplementary characters (surrogate pairs) accepted.
802
+ const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
803
+ 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});
804
+ }
773
805
  const children = [
774
806
  tag("rect", {
775
807
  x: box.x,
@@ -780,55 +812,57 @@ function renderCode(item, box, bound, options) {
780
812
  stroke: "#334155",
781
813
  "stroke-width": 1,
782
814
  ...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
815
  })
802
816
  ];
803
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
817
+ for (const part of layout.parts) {
818
+ 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});
819
+ const lines=part.fit.sourceLines.map((line,index)=>tag('text',{
820
+ x:stableNumber(part.box.x),y:stableNumber(part.box.y+part.fit.fontSize+index*part.fit.lineHeight),
821
+ 'text-anchor':'start','font-family':fontStack(part.style.fontFamily,'monospace'),
822
+ 'font-size':stableNumber(part.fit.fontSize),'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
823
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='body'?'#E5E7EB':'#93C5FD',
824
+ ...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
825
+ '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}:{}),
826
+ },line.segments.map(segment=>tag('tspan',{
827
+ x:stableNumber(part.box.x+segment.x),
828
+ // SVG's CSS tab-size does not place literal tabs at their measured stops.
829
+ ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
830
+ ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
831
+ },escapeText(part.text.slice(segment.start,segment.end)))).join('')));
832
+ children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-code-role':part.role,'data-opf-generated':part.generated?'true':undefined,
833
+ '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}:{}),
834
+ ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
835
+ }
836
+ return tag("g", {...traceAttrs(options,item.path),...(options.trace?{'data-opf-code-container':'true'}:{})}, children.join("\n"));
804
837
  }
805
838
 
806
839
  function renderMetric(item, box, bound, options) {
807
- const metric = isPlainObject(item.value) ? item.value : { value: item.value };
808
- const children = [
809
- renderTextBox(String(metric.value ?? ""), { ...box, height: box.height * 0.45 }, bound, {
810
- path: `${item.path}.value`,
811
- fontSize: Math.min(76, box.height * 0.28),
812
- fontFamily: bound.design.fonts.heading,
813
- fontWeight: 800,
814
- fill: bound.design.colors.primary,
815
- options
816
- }),
817
- renderTextBox([metric.label, metric.description, metric.delta].filter(Boolean).join("\n"), {
818
- x: box.x,
819
- y: box.y + box.height * 0.45,
820
- width: box.width,
821
- height: box.height * 0.55
822
- }, bound, {
823
- path: item.path,
824
- fontSize: 23,
825
- fontFamily: bound.design.fonts.body,
826
- fontWeight: 500,
827
- fill: bound.design.colors.text,
828
- options
829
- })
830
- ];
831
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
840
+ const layout=item.metricLayout;
841
+ if (!layout) throw new OPFRenderError('missing-metric-layout','Metric rendering requires coordinated core metric geometry.',{path:item.path});
842
+ const children=[];
843
+ for (const part of layout.parts) {
844
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
845
+ if (invalid) throw new OPFRenderError('invalid-metric-text',`Metric 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});
846
+ if (!part.visible) continue;
847
+ if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFRenderError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
848
+ const lines=part.fit.sourceLines.map((line,index)=>{
849
+ const origin=part.linePositions[index];
850
+ return tag('text',{x:stableNumber(origin.x),y:stableNumber(origin.baseline),'text-anchor':'start',
851
+ 'font-family':fontStack(part.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(part.fit.fontSize),
852
+ 'font-weight':part.style.fontWeight,'font-style':part.style.italic?'italic':undefined,
853
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':'geometricPrecision',fill:part.role==='value'?bound.design.colors.primary:bound.design.colors.text,
854
+ ...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,'data-opf-text-start':line.start,
855
+ 'data-opf-text-end':line.end,'data-opf-text-next-start':line.nextStart,'data-opf-line-boundary':line.boundary}:{}),
856
+ },line.segments.map(segment=>tag('tspan',{x:stableNumber(origin.x+segment.x),
857
+ ...(segment.kind==='tab'?{textLength:stableNumber(segment.width),lengthAdjust:'spacingAndGlyphs'}:{}),
858
+ ...(options.trace?{'data-opf-segment':segment.kind,'data-opf-text-start':segment.start,'data-opf-text-end':segment.end}:{}),
859
+ },escapeText(part.text.slice(segment.start,segment.end)))).join(''));
860
+ });
861
+ children.push(tag('g',{...traceAttrs(options,part.path),...(options.trace?{'data-opf-metric-role':part.role,
862
+ '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}:{}),
863
+ ...(part.fit.overflow?{'data-opf-overflow':'true'}:{})},lines.join('\n')));
864
+ }
865
+ return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-metric-container':'true'}:{})},children.join('\n'));
832
866
  }
833
867
 
834
868
  function renderQuote(item, box, bound, options) {
@@ -846,53 +880,19 @@ function renderQuote(item, box, bound, options) {
846
880
  }
847
881
 
848
882
  function renderTimeline(item, box, bound, options) {
849
- const timeline = Array.isArray(item.value) ? { events: item.value } : item.value;
850
- const events = Array.isArray(timeline?.events) ? timeline.events : [];
851
- if (!events.length) return "";
852
- const labelWidth = box.width / Math.max(2, events.length);
853
- const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
854
- const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
855
- const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
856
- const y = box.y + box.height * 0.46;
857
- const children = [
858
- tag("line", {
859
- x1: start,
860
- x2: end,
861
- y1: y,
862
- y2: y,
863
- stroke: bound.design.colors.border,
864
- "stroke-width": 3,
865
- ...traceAttrs(options, item.path)
866
- })
867
- ];
868
-
869
- events.forEach((event, index) => {
870
- const x = start + index * gap;
871
- const eventPath = `${item.path}.events.${index}`;
872
- children.push(tag("circle", {
873
- cx: x,
874
- cy: y,
875
- r: Math.min(9, labelWidth / 5, box.height * 0.04),
876
- fill: bound.design.colors.primary,
877
- ...traceAttrs(options, eventPath)
878
- }));
879
- children.push(renderTextBox([event.when, event.what, event.description].filter(Boolean).join("\n"), {
880
- x: x - labelWidth / 2,
881
- y: index % 2 === 0 ? box.y : y + 24,
882
- width: labelWidth,
883
- height: box.height * 0.38
884
- }, bound, {
885
- path: eventPath,
886
- fontSize: 16,
887
- fontFamily: bound.design.fonts.body,
888
- fontWeight: 500,
889
- fill: bound.design.colors.text,
890
- options,
891
- align: "center"
892
- }));
893
- });
894
-
895
- return tag("g", traceAttrs(options, item.path), children.join("\n"));
883
+ const layout=item.timelineLayout;
884
+ if(!layout)throw new OPFRenderError('missing-timeline-layout','Timeline rendering requires a coordinated core build with shared timeline geometry.',{path:item.path});
885
+ const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
886
+ const children=[tag('line',{...layout.connector,stroke:bound.design.colors.border,'stroke-width':3*scale,...traceAttrs(options,item.path)})];
887
+ for(const marker of layout.markers)children.push(tag('circle',{cx:marker.x,cy:marker.y,r:marker.radius,fill:bound.design.colors.primary,...traceAttrs(options,marker.path)}));
888
+ for(const part of layout.parts){
889
+ if(!part.fit)throw new OPFRenderError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
890
+ children.push(tag('g',options.trace?{'data-opf-timeline-role':part.role}:{},renderTextBox(part.text,part.box,bound,{
891
+ path:part.path,fit:part.fit,textStyle:part.style,fontFamily:part.requestedStyle.fontFamily,align:part.alignment,
892
+ fill:bound.design.colors.text,diagnosticsHandled:true,options,
893
+ })));
894
+ }
895
+ return tag('g',{...traceAttrs(options,item.path),...(options.trace?{'data-opf-timeline-arrangement':layout.arrangement}:{})},children.join('\n'));
896
896
  }
897
897
 
898
898
  function renderTable(item, box, bound, options) {
@@ -903,10 +903,11 @@ function renderTable(item, box, bound, options) {
903
903
  const defaultEdges = [], explicitEdges = [];
904
904
  for (const row of layout.rows) for (const cell of row.cells) {
905
905
  const style = cell.style ?? {};
906
+ const fill = style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface);
906
907
  children.push(tag("rect", {
907
908
  x: stableNumber(cell.box.x), y: stableNumber(cell.box.y),
908
909
  width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
909
- fill: style.fill ?? (cell.header ? bound.design.colors.primary : bound.design.colors.surface),
910
+ fill,
910
911
  stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
911
912
  ...traceAttrs(options, cell.sourcePath ?? cell.path)
912
913
  }));
@@ -922,7 +923,7 @@ function renderTable(item, box, bound, options) {
922
923
  children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
923
924
  path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
924
925
  fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
925
- fill:style.color ?? (cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
926
+ fill:style.color ?? textColorForFill(fill, cell.header ? "#FFFFFF" : bound.design.colors.text), align:style.align, options
926
927
  }));
927
928
  }
928
929
 
@@ -967,9 +968,10 @@ function renderImportedChart(item, box, bound, options) {
967
968
  const chart=item.value, rows=chart.data?.rows??[], columns=chart.data?.columns??[];
968
969
  if(!rows.length||columns.length<2)return null;
969
970
  const kind=chart.type, circular=['pie','donut','doughnut'].includes(kind);
970
- const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'];
971
+ const colors=[bound.design.colors.primary,bound.design.colors.secondary,'#9B6BCC','#D98944','#429B85','#CB5D79'].map(color=>chartColorForFill(bound.design.colors.surface,color));
971
972
  const children=[];
972
- const text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:bound.design.colors.text,options,align,verticalAlign:'middle'});
973
+ const labelColor=textColorForFill(bound.design.colors.surface,bound.design.colors.text);
974
+ const text=(value,rect,path,size=16,align='center')=>renderTextBox(String(value),rect,bound,{path,fontSize:size,fontFamily:bound.design.fonts.body,fontWeight:400,fill:labelColor,options,align,verticalAlign:'middle'});
973
975
  const number=value=>typeof value==='number'&&Number.isFinite(value)?value:typeof value==='string'&&value.trim()&&Number.isFinite(Number(value))?Number(value):null;
974
976
  const series=columns.slice(1).map((name,j)=>({name,values:rows.map(row=>number(row[j+1]))}));
975
977
  children.push(tag('rect',{x:box.x,y:box.y,width:box.width,height:box.height,fill:bound.design.colors.surface,stroke:bound.design.colors.border,...traceAttrs(options,item.path)}));
@@ -1021,7 +1023,7 @@ function renderImportedChart(item, box, bound, options) {
1021
1023
  });
1022
1024
  }
1023
1025
  });
1024
- children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:bound.design.colors.text,'stroke-width':1}));
1026
+ children.push(tag('line',{x1:horizontal?zero:plot.x,x2:horizontal?zero:plot.x+plot.width,y1:horizontal?plot.y:zero,y2:horizontal?plot.y+plot.height:zero,stroke:labelColor,'stroke-width':1}));
1025
1027
  }
1026
1028
  return tag('g',traceAttrs(options,item.path),children.join('\n'));
1027
1029
  }
@@ -1036,13 +1038,17 @@ function renderChart(item, box, bound, options) {
1036
1038
  const data = inlineChartRows(chart.data);
1037
1039
  const plot = inset(box, 28);
1038
1040
  const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
1041
+ const panelFill = bound.design.colors.surface;
1042
+ const labelColor = textColorForFill(panelFill, bound.design.colors.text);
1043
+ const primary = chartColorForFill(panelFill, bound.design.colors.primary);
1044
+ const secondary = chartColorForFill(panelFill, bound.design.colors.secondary);
1039
1045
  const children = [
1040
1046
  tag("rect", {
1041
1047
  x: box.x,
1042
1048
  y: box.y,
1043
1049
  width: box.width,
1044
1050
  height: box.height,
1045
- fill: "#FFFFFF",
1051
+ fill: panelFill,
1046
1052
  stroke: bound.design.colors.border,
1047
1053
  "stroke-width": 1,
1048
1054
  ...traceAttrs(options, item.path)
@@ -1055,7 +1061,7 @@ function renderChart(item, box, bound, options) {
1055
1061
  fontSize: 20,
1056
1062
  fontFamily: bound.design.fonts.body,
1057
1063
  fontWeight: 500,
1058
- fill: bound.design.colors.mutedText,
1064
+ fill: labelColor,
1059
1065
  options,
1060
1066
  align: "center",
1061
1067
  verticalAlign: "middle"
@@ -1072,7 +1078,7 @@ function renderChart(item, box, bound, options) {
1072
1078
  children.push(tag("polyline", {
1073
1079
  points: points.map(([x, y]) => `${stableNumber(x)},${stableNumber(y)}`).join(" "),
1074
1080
  fill: "none",
1075
- stroke: bound.design.colors.primary,
1081
+ stroke: primary,
1076
1082
  "stroke-width": 4,
1077
1083
  ...traceAttrs(options, `${item.path}.data`)
1078
1084
  }));
@@ -1080,7 +1086,7 @@ function renderChart(item, box, bound, options) {
1080
1086
  cx: stableNumber(x),
1081
1087
  cy: stableNumber(y),
1082
1088
  r: 5,
1083
- fill: bound.design.colors.primary,
1089
+ fill: primary,
1084
1090
  ...traceAttrs(options, `${item.path}.data.rows.${index}`)
1085
1091
  })));
1086
1092
  } else {
@@ -1095,7 +1101,7 @@ function renderChart(item, box, bound, options) {
1095
1101
  y: stableNumber(y),
1096
1102
  width: stableNumber(barWidth),
1097
1103
  height: stableNumber(barHeight),
1098
- fill: index % 2 === 0 ? bound.design.colors.primary : bound.design.colors.secondary,
1104
+ fill: index % 2 === 0 ? primary : secondary,
1099
1105
  ...traceAttrs(options, `${item.path}.data.rows.${index}`)
1100
1106
  }));
1101
1107
  });
@@ -1111,7 +1117,7 @@ function renderChart(item, box, bound, options) {
1111
1117
  fontSize: 12,
1112
1118
  fontFamily: bound.design.fonts.body,
1113
1119
  fontWeight: 400,
1114
- fill: bound.design.colors.mutedText,
1120
+ fill: labelColor,
1115
1121
  options,
1116
1122
  align: "center"
1117
1123
  }));
@@ -1141,7 +1147,7 @@ function renderFurniture(bound, presentation, width, height, options, kind) {
1141
1147
  return tag('g',traceAttrs(options,root),['left','center','right'].map((zone,index)=>{
1142
1148
  const item=furniture[zone];if(!item)return '';
1143
1149
  const box={x:width*(.07+index*.3),y:kind==='header'?height*.025:height*.925,width:width*.26,height:height*.05};
1144
- if(item.image)return renderImage({value:item.image,path:`${root}.${zone}.image`},box,bound,options);
1150
+ if(item.image)return renderImage({value:item.image,path:`${root}.${zone}.image`},box,bound,{...options,imageFit:'contain'});
1145
1151
  const pieces=[item.text,item.organization?organization?.name:null,item.section?bound.slide.section:null,item.slideNumber?String(bound.index+1):null,typeof item.date==='string'?item.date:null].filter(value=>value!==undefined&&value!==null&&value!=='');
1146
1152
  if(item.date===true)reportDiagnostic({code:'date-needs-value',path:`${root}.${zone}.date`,message:'Use a literal date string for a reproducible preview; the document does not define a presentation date.'},options);
1147
1153
  const anchor=['start','middle','end'][index],x=index===0?width*.07:index===1?width/2:width*.93;
@@ -1152,7 +1158,7 @@ function renderBranding(bound,presentation,width,height,options) {
1152
1158
  const design={...presentation.design,...bound.slide.design}, pieces=[];
1153
1159
  const rootFor=key=>bound.slide.design?.[key]!==undefined?`${bound.path}.design.${key}`:`design.${key}`;
1154
1160
  if(design.watermark){
1155
- pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,options)));
1161
+ pieces.push(tag('g',{opacity:typeof design.watermark==='object'?design.watermark.opacity??.08:.08},renderImage({value:design.watermark,path:rootFor('watermark')},{x:width*.3,y:height*.3,width:width*.4,height:height*.4},bound,{...options,imageFit:'contain'})));
1156
1162
  }
1157
1163
  return pieces.join('');
1158
1164
  }
@@ -1175,32 +1181,50 @@ function renderEmbeddedFonts(fonts = []) {
1175
1181
  function renderRichTextBox(value, box, bound, config) {
1176
1182
  const scale=Math.min(bound.design.dimensions.width,bound.design.dimensions.height)/720;
1177
1183
  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});
1178
- 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]});}
1184
+ if(fit.overflow&&!config.diagnosticsHandled){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]});}
1179
1185
  return renderRichLines(value,fit,box,bound,config);
1180
1186
  }
1181
1187
 
1182
1188
  function renderRichLines(value,fit,box,bound,config) {
1183
- const alignment=config.align??bound.design.contentAlignment;
1189
+ const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1184
1190
  let textOffset=0;
1185
1191
  const runOffsets=value.map(run=>{const start=textOffset;textOffset+=(typeof run==='string'?run:run.text).length;return start;});
1186
- const content=fit.richLines.flatMap(line=>line.fragments.map(fragment=>{
1192
+ // With no measurement provider, fragment advances are estimates. Let SVG
1193
+ // shape adjacent runs naturally inside each estimated line instead of turning
1194
+ // those estimates into visible gaps. Supplied measurements keep exact origins.
1195
+ const flow=!config.options.textMeasurement?.measure&&!fit.placement;
1196
+ const content=fit.richLines.map((line,lineIndex)=>{
1197
+ const fragments=line.fragments.map(fragment=>{
1187
1198
  const run=fragment.run,offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1188
- const rendered=tag('text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),x:stableNumber(box.x+offset+fragment.x),y:stableNumber(box.y+line.baseline+fragment.baselineShift),'xml:space':'preserve','font-family':fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color??'')?run.color:config.fill},escapeText(fragment.text));
1199
+ const placed=fit.placement?.lines[lineIndex];
1200
+ // Accepted outline placement owns the horizontal advance. Geometric precision
1201
+ // avoids hinted browser advances; textLength also removes fractional-size
1202
+ // quantization drift. Height and baseline retain the selected font size.
1203
+ const position=flow?{'baseline-shift':fragment.baselineShift?stableNumber(-fragment.baselineShift):undefined}:{x:stableNumber((placed?.x??box.x+offset)+fragment.x),y:stableNumber((placed?.baseline??box.y+line.baseline)+fragment.baselineShift)};
1204
+ const rendered=tag(flow?'tspan':'text',{...(config.options.trace?{'data-opf-text-start':runOffsets[fragment.runIndex]+fragment.start,'data-opf-text-end':runOffsets[fragment.runIndex]+fragment.end}:{}),...position,'xml:space':'preserve','text-rendering':flow?undefined:'geometricPrecision',textLength:placed&&fragment.width>0?stableNumber(fragment.width):undefined,lengthAdjust:placed&&fragment.width>0?'spacingAndGlyphs':undefined,'font-family':fontStack(fragment.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(fragment.fontSize),'font-weight':fragment.style.fontWeight,'font-style':fragment.style.italic?'italic':flow?'normal':undefined,'text-decoration':[run.underline?'underline':'',run.strikethrough?'line-through':''].filter(Boolean).join(' ')||undefined,fill:/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color??'')?run.color:config.fill},escapeText(fragment.text));
1189
1205
  if(run.link&&/^(https?:|mailto:)/i.test(run.link))return tag('a',{href:run.link,target:'_blank',rel:'noopener noreferrer'},rendered);
1190
1206
  return rendered;
1191
- }));
1207
+ });
1208
+ if(!flow)return fragments.join('\n');
1209
+ const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
1210
+ const first=line.fragments[0];
1211
+ return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined},fragments.join(''));
1212
+ });
1192
1213
  let cursor=0;
1193
1214
  const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
1194
1215
  const lineTrace=config.options.trace?fit.richLines.map((line,index)=>{
1195
1216
  if(index){const newline=/^(\r\n|\r|\n)/.exec(whole.slice(cursor));if(newline)cursor+=newline[0].length;}
1196
1217
  const start=cursor;cursor+=(fit.lines[index]??'').length;
1197
1218
  const offset=alignment==='right'?box.width-line.width:alignment==='center'?(box.width-line.width)/2:0;
1198
- return {start,end:cursor,x:box.x+offset,y:box.y+line.y,height:line.height};
1219
+ const placed=fit.placement?.lines[index];
1220
+ return {start,end:cursor,x:placed?.x??box.x+offset,y:placed?.y??box.y+line.y,height:placed?.height??line.height};
1199
1221
  }):undefined;
1200
- return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace)}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
1222
+ return tag('g',{...traceAttrs(config.options,config.path),...(config.options.trace?{'data-opf-box-width':box.width,'data-opf-rich-text':config.rich===false?undefined:'true','data-opf-rich-lines':JSON.stringify(lineTrace),'data-opf-rich-spacing':flow?'natural':'measured'}:{}),...(fit.overflow?{'data-opf-overflow':'true'}:{})},content.join('\n'));
1201
1223
  }
1202
1224
 
1203
1225
  function renderTextBox(text, box, bound, config) {
1226
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
1227
+ if(invalid)throw new OPFRenderError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which SVG XML cannot represent.`,{path:config.path});
1204
1228
  const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
1205
1229
  const style = config.textStyle ?? resolveTextStyle({fontFamily:config.fontFamily,fontWeight:config.fontWeight ?? 400,italic:config.italic ?? false,path:config.path},config.options.textMeasurement);
1206
1230
  const fit = config.fit ?? fitText(String(text ?? ""), box, config.fontSize * scale,
@@ -1215,16 +1239,30 @@ function renderTextBox(text, box, bound, config) {
1215
1239
  const totalHeight = fit.lines.length * fit.lineHeight;
1216
1240
  const startY = config.verticalAlign === "middle"
1217
1241
  ? box.y + Math.max(0, (box.height - totalHeight) / 2) + size : box.y + size;
1218
- const alignment=config.align??bound.design.contentAlignment;
1242
+ const alignment=fit.placement?.alignment??config.align??bound.design.contentAlignment;
1219
1243
  const anchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
1220
1244
  const x = alignment === "center" ? box.x + box.width / 2 : alignment === "right" ? box.x + box.width : box.x;
1221
- const lines = fit.lines.map((line, index) => tag("text", {
1222
- x: stableNumber(x), y: stableNumber(startY + index * fit.lineHeight),
1223
- "text-anchor": anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1245
+ const lines = fit.lines.map((line, index) => {
1246
+ const sourceLine=fit.sourceLines?.[index],placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1247
+ const origin=placed?.x??x-(sourceLine?.width??0)*factor;
1248
+ const tabs=sourceLine?.segments.some(segment=>segment.kind==='tab');
1249
+ const content=tabs?sourceLine.segments.map(segment=>tag('tspan',{
1250
+ x:stableNumber(origin+segment.x),textLength:segment.kind==='tab'||placed?stableNumber(segment.width):undefined,
1251
+ lengthAdjust:segment.kind==='tab'||placed?'spacingAndGlyphs':undefined,
1252
+ ...(config.options.trace?{'data-opf-source-start':segment.start,'data-opf-source-end':segment.end,'data-opf-segment':segment.kind}:{}),
1253
+ },escapeText(line.slice(segment.start-sourceLine.start,segment.end-sourceLine.start)))).join(''):escapeText(line);
1254
+ return tag("text", {
1255
+ x: stableNumber(tabs?origin:placed?placed.x+placed.width*factor:x), y: stableNumber(placed?.baseline??startY + index * fit.lineHeight),
1256
+ "text-anchor": tabs?'start':anchor, "font-family": fontStack(style.fontFamily, config.fontFamily === bound.design.fonts.code ? "monospace" : bound.design.fontScheme.type),
1224
1257
  "font-size": stableNumber(size), "font-weight": style.fontWeight, "font-style": style.italic ? "italic" : undefined, fill: config.fill,
1225
- ...traceAttrs(config.options, config.path)
1226
- }, escapeText(line)));
1258
+ 'xml:space':'preserve',style:'white-space:pre','text-rendering':config.options.textMeasurement?.measure?'geometricPrecision':undefined,
1259
+ textLength:!tabs&&placed?.width>0?stableNumber(placed.width):undefined,lengthAdjust:!tabs&&placed?.width>0?'spacingAndGlyphs':undefined,
1260
+ ...traceAttrs(config.options, config.path),
1261
+ ...(config.options.trace&&sourceLine?{'data-opf-source-start':sourceLine.start,'data-opf-source-end':sourceLine.end,'data-opf-source-next-start':sourceLine.nextStart,'data-opf-line-boundary':sourceLine.boundary}:{}),
1262
+ }, content);
1263
+ });
1227
1264
  return tag("g", { ...traceAttrs(config.options, config.path),
1265
+ ...(config.options.trace&&fit.sourceLines?{'data-opf-source-text':'true','data-opf-box-x':box.x,'data-opf-box-y':box.y,'data-opf-box-width':box.width,'data-opf-box-height':box.height}:{}),
1228
1266
  ...(fit.overflow ? { "data-opf-overflow": "true" } : {}) }, lines.join("\n"));
1229
1267
  }
1230
1268
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "engines": {
9
- "node": ">=20.9.0"
9
+ "node": "24.x"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -55,16 +55,29 @@
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 && node --check test/styled-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 && node --check test/chart-colors.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.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/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",
60
+ "test": "npm run build && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/chart-colors.mjs && 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
62
  "test:packed": "node test/packed-install.mjs",
63
63
  "test:browser": "npm run build:browser-check && node test/browser-check.mjs",
64
64
  "golden:update": "npm run build && node test/golden.mjs --update",
65
65
  "prepack": "npm run build",
66
66
  "build:browser-check": "node scripts/build-browser-check.mjs",
67
- "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",
70
+ "test:metric": "node test/shared-metric.mjs",
71
+ "test:images-browser": "node test/image-placeholders-browser.mjs",
72
+ "test:font-preparation": "node test/font-preparation.mjs artifacts/font-preparation.json",
73
+ "test:font-preparation-browser": "node test/font-preparation-browser.mjs artifacts/font-preparation-browser.json",
74
+ "test:font-variants": "node test/font-variants.mjs",
75
+ "test:font-variants-browser": "node test/font-variants-browser.mjs",
76
+ "test:text": "node test/font-outlines.mjs && node test/accepted-text.mjs",
77
+ "test:text-browser": "node test/accepted-text-browser.mjs",
78
+ "test:rich-spacing-browser": "node test/rich-spacing-browser.mjs",
79
+ "test:rich-flow-browser": "node test/rich-flow-browser.mjs",
80
+ "test:plain-whitespace-browser": "node test/plain-whitespace-browser.mjs"
68
81
  },
69
82
  "dependencies": {
70
83
  "@expo-google-fonts/arimo": "0.4.3",
@@ -72,10 +85,10 @@
72
85
  "@expo-google-fonts/carlito": "0.4.1",
73
86
  "@expo-google-fonts/cousine": "0.4.3",
74
87
  "@expo-google-fonts/gelasio": "0.4.1",
75
- "@expo-google-fonts/roboto": "^0.4.3",
76
- "@expo-google-fonts/roboto-mono": "^0.4.2",
88
+ "@expo-google-fonts/roboto": "0.4.3",
89
+ "@expo-google-fonts/roboto-mono": "0.4.2",
77
90
  "@expo-google-fonts/tinos": "0.4.2",
78
- "@openpresentation/opf": "^0.8.0",
91
+ "@openpresentation/opf": "^0.10.0",
79
92
  "@resvg/resvg-js": "^2.6.2",
80
93
  "fontkit": "^2.0.4",
81
94
  "pdf-lib": "^1.17.1",