@office-kit/pptx-preview 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/index.js +1 -1
- package/dist/node.d.ts +9 -0
- package/dist/node.js +2 -1
- package/dist/node.js.map +1 -1
- package/dist/{src-BObFGqe2.js → src-DoFuEaap.js} +36 -17
- package/dist/src-DoFuEaap.js.map +1 -0
- package/package.json +1 -1
- package/dist/src-BObFGqe2.js.map +0 -1
package/README.md
CHANGED
|
@@ -116,6 +116,20 @@ regular and the bold one — and register them with `buildFontkitMeasurer({ font
|
|
|
116
116
|
for the measurer. East Asian characters are drawn in the family of the first
|
|
117
117
|
`fontFiles` face that covers kana, in the face closest to each run's weight.
|
|
118
118
|
|
|
119
|
+
Every authored font name is drawn in a bundled substitute unless you pass
|
|
120
|
+
`resolveFamily`. Map a deck's font to a family you load through `fontFiles`
|
|
121
|
+
(and register with the measurer) to draw the run in that face; East Asian
|
|
122
|
+
characters keep the kana face above, and `resolveFamily` decides the rest.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const noto = 'fonts/NotoSansCJKjp-Regular.otf';
|
|
126
|
+
const png = renderSlideToImage(pres, slide, {
|
|
127
|
+
fontFiles: [noto],
|
|
128
|
+
measureText: buildFontkitMeasurer({ fonts: [{ family: 'Noto Sans CJK JP', source: noto }] }),
|
|
129
|
+
resolveFamily: (family) => (family === 'Meiryo' ? 'Noto Sans CJK JP' : substituteFamily(family)),
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
119
133
|
### Text-layout audit (overflow / soft-wrap detection)
|
|
120
134
|
|
|
121
135
|
```ts
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-
|
|
1
|
+
import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-DoFuEaap.js";
|
|
2
2
|
export { ARIAL, MONO, SANS, SERIF, TIMES, auditTextLayout, decimalSeparatorOf, defaultMeasurer, paragraphNumberLabels, renderSlideLayoutSvg as renderSlideLayoutToSvg, renderSlideSvg as renderSlideToSvg, renderTextEffectsSvg, resolveTextBodyRect, shapeAutoFitScale, shapeTextAnchorOffset, shapeTextRect, substituteFamily, textColumnsStyle, textUnderlineStyle, verticalTextStyle };
|
package/dist/node.d.ts
CHANGED
|
@@ -62,6 +62,15 @@ interface RenderImageOptions {
|
|
|
62
62
|
* deterministic.
|
|
63
63
|
*/
|
|
64
64
|
readonly loadSystemFonts?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Maps an authored font name to the family drawn and measured, as
|
|
67
|
+
* {@link RenderSlideOptions.resolveFamily}. Defaults to `substituteFamily`.
|
|
68
|
+
* Return a family loaded through `fontFiles` (and registered with the
|
|
69
|
+
* measurer) to draw a deck's own font instead of a bundled substitute.
|
|
70
|
+
* East Asian characters still take the family of the first `fontFiles`
|
|
71
|
+
* face that covers kana; this only decides the rest of the run.
|
|
72
|
+
*/
|
|
73
|
+
readonly resolveFamily?: RenderSlideOptions['resolveFamily'];
|
|
65
74
|
}
|
|
66
75
|
/** Render one slide to PNG-encoded bytes. */
|
|
67
76
|
declare const renderSlideToImage: (pres: PresentationData, slide: SlideData, opts?: RenderImageOptions) => Uint8Array;
|
package/dist/node.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, c as AVG_GLYPH_W_RATIO, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, m as isCjk, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-
|
|
1
|
+
import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, c as AVG_GLYPH_W_RATIO, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, m as isCjk, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-DoFuEaap.js";
|
|
2
2
|
import { getSlideSize } from "@office-kit/pptx";
|
|
3
3
|
import { Resvg } from "@resvg/resvg-js";
|
|
4
4
|
import * as fontkit from "fontkit";
|
|
@@ -190,6 +190,7 @@ const rasterize = (pres, slide, opts) => {
|
|
|
190
190
|
return new Resvg(renderSlideSvg(pres, slide, {
|
|
191
191
|
measureText,
|
|
192
192
|
textLayout: "svg",
|
|
193
|
+
...opts.resolveFamily === void 0 ? {} : { resolveFamily: opts.resolveFamily },
|
|
193
194
|
...eastAsianFamily === void 0 ? {} : { eastAsianFamily }
|
|
194
195
|
}), {
|
|
195
196
|
fitTo: {
|
package/dist/node.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.js","names":[],"sources":["../src/measure.ts","../src/node.ts"],"sourcesContent":["// Node-only fontkit-backed TextMeasurer. NEVER imported by render-slide.ts /\n// text-layout.ts (which must stay browser-safe) — only by the node entry. It\n// measures advance widths and vertical metrics from the same bundled TTFs that\n// resvg rasterizes with (and that LibreOffice renders ground truth with), so\n// the engine's wrap/positioning math agrees with the painted pixels.\n\nimport * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport {\n ARIAL,\n AVG_GLYPH_W_RATIO,\n isCjk,\n MONO,\n SANS,\n SERIF,\n substituteFamily,\n TIMES,\n type FontSpec,\n type MeasureResult,\n type TextMeasurer,\n} from './text-layout.ts';\n\nexport const FONT_DIR = fileURLToPath(new URL('../fonts/', import.meta.url));\n\n// Internal family name (what the emitter writes + resvg matches) → file prefix.\nconst FAMILY_TO_PREFIX: Record<string, string> = {\n [SANS]: 'Carlito',\n [SERIF]: 'Caladea',\n [ARIAL]: 'LiberationSans',\n [TIMES]: 'LiberationSerif',\n [MONO]: 'LiberationMono',\n};\n\nconst STYLES = ['Regular', 'Bold', 'Italic', 'BoldItalic'] as const;\n\nconst facePath = (prefix: string, style: string): string => `${FONT_DIR}${prefix}-${style}.ttf`;\n\n/** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */\nexport const FONT_FILES: string[] = Object.values(FAMILY_TO_PREFIX).flatMap((prefix) =>\n STYLES.map((s) => facePath(prefix, s)),\n);\n\n// fontkit.create returns Font | FontCollection; our TTFs are single fonts.\nconst openFont = (path: string): fontkit.Font => {\n const font = fontkit.create(readFileSync(path));\n if (!('layout' in font)) {\n throw new Error(`Expected a single font in ${path}, got a collection`);\n }\n return font;\n};\n\nconst styleSuffix = (spec: { bold: boolean; italic: boolean }): (typeof STYLES)[number] => {\n if (spec.bold && spec.italic) return 'BoldItalic';\n if (spec.bold) return 'Bold';\n if (spec.italic) return 'Italic';\n return 'Regular';\n};\n\n/** A font the caller supplies for measurement, keyed by the AUTHORED family\n * name (`<a:latin typeface>` / `<a:ea typeface>` — e.g. \"游ゴシック\",\n * \"Montserrat\"), which routinely differs from the file's internal name. */\nexport interface RegisteredFont {\n readonly family: string;\n /** Path to a ttf/otf file, or its bytes. */\n readonly source: string | Uint8Array;\n readonly bold?: boolean;\n readonly italic?: boolean;\n}\n\nexport interface FontkitMeasurerOptions {\n /** Fonts the deck actually uses. A run whose family matches a registered\n * font measures with it; any registered font also serves as a fallback\n * for glyphs the resolved font lacks (the bundled faces are Latin-only,\n * so this is how CJK text gets real metrics). */\n readonly fonts?: ReadonlyArray<RegisteredFont>;\n}\n\nconst openFontSource = (source: string | Uint8Array): fontkit.Font => {\n if (typeof source === 'string') return openFont(source);\n const font = fontkit.create(Buffer.from(source));\n if (!('layout' in font)) {\n throw new Error('Expected a single font, got a collection');\n }\n return font;\n};\n\n// Per-character width estimate for glyphs no available font covers — the same\n// ratios as the heuristic measurer, so estimates and defaultMeasurer agree.\nconst estimateCharWidth = (ch: string, sizePx: number): number =>\n sizePx * (isCjk(ch.codePointAt(0) ?? 0) ? 1 : AVG_GLYPH_W_RATIO);\n\nexport const buildFontkitMeasurer = (options: FontkitMeasurerOptions = {}): TextMeasurer => {\n const cache = new Map<string, fontkit.Font>();\n // Load + verify every face up front. The familyName assertion guarantees the\n // emit-side family === resvg match === the face we measure, which is the load-\n // bearing invariant for \"x= is where glyphs land\".\n for (const [family, prefix] of Object.entries(FAMILY_TO_PREFIX)) {\n for (const style of STYLES) {\n const path = facePath(prefix, style);\n if (!existsSync(path)) throw new Error(`Missing bundled font: ${path}`);\n const font = openFont(path);\n if (style === 'Regular' && font.familyName !== family) {\n throw new Error(\n `Font family mismatch: ${path} reports \"${font.familyName}\", expected \"${family}\". ` +\n `The substitution map and resvg matching rely on this name.`,\n );\n }\n cache.set(`${prefix}-${style}`, font);\n }\n }\n\n // Registered fonts, keyed by lowercased authored family + style. No\n // familyName assertion here: the registration key is the DECK's name for\n // the font, which legitimately differs from the file's internal name.\n const registered = new Map<string, fontkit.Font>();\n const fallbackFonts: fontkit.Font[] = [];\n for (const reg of options.fonts ?? []) {\n const font = openFontSource(reg.source);\n const style = styleSuffix({ bold: reg.bold ?? false, italic: reg.italic ?? false });\n registered.set(`${reg.family.toLowerCase()}-${style}`, font);\n fallbackFonts.push(font);\n }\n\n const pick = (spec: FontSpec): fontkit.Font => {\n const style = styleSuffix(spec);\n const regKey = spec.family.toLowerCase();\n const reg = registered.get(`${regKey}-${style}`) ?? registered.get(`${regKey}-Regular`);\n if (reg) return reg;\n // The render path pre-substitutes families (spec.family is a bundled\n // internal name); the audit path passes the authored name through, so\n // resolve it with the same map before keying the bundled faces.\n const prefix =\n FAMILY_TO_PREFIX[spec.family] ?? FAMILY_TO_PREFIX[substituteFamily(spec.family)]!;\n return (\n cache.get(`${prefix}-${style}`) ??\n cache.get(`${prefix}-Regular`) ??\n cache.get('Carlito-Regular')!\n );\n };\n\n // Splits `text` into maximal segments coverable by one font: the resolved\n // font first, then each registered font in registration order. Characters\n // no font covers fall back to the per-character estimate (approximate).\n const measureWithFallback = (\n text: string,\n spec: FontSpec,\n primary: fontkit.Font,\n ): MeasureResult => {\n const chain = [primary, ...fallbackFonts.filter((f) => f !== primary)];\n let widthPx = 0;\n let approximate = false;\n let metricsFont: fontkit.Font | null = null;\n // Keep the bounds signed until the end. Initialising either side at zero\n // would turn a glyph entirely above/below the baseline into a false ink\n // extent on the opposite side.\n let inkTop = Number.NEGATIVE_INFINITY;\n let inkBottom = Number.POSITIVE_INFINITY;\n let segment = '';\n let segmentFont: fontkit.Font | null = null;\n const flush = (): void => {\n if (segment === '' || segmentFont === null) return;\n const scale = spec.sizePx / segmentFont.unitsPerEm;\n const run = segmentFont.layout(segment, { kern: spec.kerning !== false });\n widthPx += run.advanceWidth * scale;\n let penY = 0;\n for (const [index, glyph] of run.glyphs.entries()) {\n const box = glyph.bbox;\n if (\n !box ||\n !Number.isFinite(box.minY) ||\n !Number.isFinite(box.maxY) ||\n box.maxY <= box.minY\n ) {\n penY += run.positions[index]?.yAdvance ?? 0;\n continue;\n }\n // fontkit reports the glyph bbox in font units and shaping can move a\n // glyph vertically (combining marks are the common case). Include the\n // shaped position so the result describes the painted glyph, rather\n // than the unpositioned outline.\n const position = run.positions[index];\n const glyphY = penY + (position?.yOffset ?? 0);\n inkTop = Math.max(inkTop, (box.maxY + glyphY) * scale);\n inkBottom = Math.min(inkBottom, (box.minY + glyphY) * scale);\n penY += position?.yAdvance ?? 0;\n }\n metricsFont ??= segmentFont;\n segment = '';\n };\n for (const ch of text) {\n const cp = ch.codePointAt(0) ?? 0;\n const font = chain.find((f) => f.hasGlyphForCodePoint(cp)) ?? null;\n if (font !== segmentFont) {\n flush();\n segmentFont = font;\n }\n if (font === null) {\n widthPx += estimateCharWidth(ch, spec.sizePx);\n approximate = true;\n } else {\n segment += ch;\n }\n }\n flush();\n const vm = verticalMetrics(metricsFont ?? primary);\n const vmScale = spec.sizePx / (metricsFont ?? primary).unitsPerEm;\n const glyphCount = [...text].length;\n const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;\n return {\n widthPx: widthPx + tracking,\n ascentPx: vm.ascent * vmScale,\n descentPx: vm.descent * vmScale,\n lineGapPx: vm.lineGap * vmScale,\n ...(!approximate && metricsFont !== null\n ? {\n inkAscentPx: inkTop === Number.NEGATIVE_INFINITY ? 0 : inkTop,\n inkDescentPx:\n inkBottom === Number.POSITIVE_INFINITY || inkBottom === 0 ? 0 : -inkBottom,\n }\n : {}),\n ...(approximate ? { approximate } : {}),\n };\n };\n\n return (text, spec) => measureWithFallback(text, spec, pick(spec));\n};\n\n// Which vertical-metric set a renderer uses depends on the OS/2 fsSelection\n// USE_TYPO_METRICS bit. When it's clear (the common case, incl. Carlito /\n// Liberation), GDI / LibreOffice place the baseline at usWinAscent and size the\n// line box as usWinAscent + usWinDescent (no extra gap). When set, they use the\n// sTypo* metrics plus typoLineGap. fontkit's `.ascent`/`.descent` expose the\n// hhea values, which for these fonts differ from usWinAscent by ~12px at 44pt —\n// exactly the baseline offset that otherwise mismatches ground truth. Returns\n// font-unit values; the caller scales to px.\ninterface VMetrics {\n readonly ascent: number;\n readonly descent: number;\n readonly lineGap: number;\n}\nconst verticalMetrics = (font: fontkit.Font): VMetrics => {\n const os2: unknown = (font as { 'OS/2'?: unknown })['OS/2'];\n if (os2 && typeof os2 === 'object') {\n const o = os2 as {\n fsSelection?: { useTypoMetrics?: boolean };\n typoAscender?: number;\n typoDescender?: number;\n typoLineGap?: number;\n winAscent?: number;\n winDescent?: number;\n };\n if (o.fsSelection?.useTypoMetrics && o.typoAscender !== undefined) {\n return {\n ascent: o.typoAscender,\n descent: Math.abs(o.typoDescender ?? 0),\n lineGap: o.typoLineGap ?? 0,\n };\n }\n if (o.winAscent !== undefined && o.winDescent !== undefined) {\n return { ascent: o.winAscent, descent: Math.abs(o.winDescent), lineGap: 0 };\n }\n }\n // Fall back to hhea metrics if OS/2 is absent.\n return { ascent: font.ascent, descent: Math.abs(font.descent), lineGap: font.lineGap };\n};\n","// `@office-kit/pptx-preview/node` — Node entry: rasterize a slide to a PNG or raw\n// RGBA image, with no browser binary.\n//\n// Pipeline: `renderSlideToSvg(..., { textLayout: 'svg', measureText })` lays\n// text out as pure `<text>`, then resvg paints the SVG to pixels. The fontkit\n// measurer, resvg's `fontFiles`, and the SVG's family names all reference the\n// SAME bundled fonts, so the wrap/positioning math agrees with the rasterized\n// glyphs.\n\nimport { Resvg } from '@resvg/resvg-js';\nimport * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { getSlideSize, type PresentationData, type SlideData } from '@office-kit/pptx';\nimport { renderSlideSvg } from './render-slide.ts';\nimport { MONO, SANS, SERIF, type TextMeasurer } from './text-layout.ts';\nimport { buildFontkitMeasurer, FONT_FILES } from './measure.ts';\n\n// Re-export the browser-safe surface so a Node consumer gets everything from\n// one import.\nexport * from './index.ts';\n\n// Advanced: pre-build / share a fontkit measurer, or locate the bundled fonts.\nexport { buildFontkitMeasurer, FONT_FILES, FONT_DIR } from './measure.ts';\nexport type { FontkitMeasurerOptions, RegisteredFont } from './measure.ts';\n\n/** Raw, un-encoded raster: RGBA bytes in row-major order. */\nexport interface RgbaImage {\n readonly width: number;\n readonly height: number;\n /** `width * height * 4` bytes, R,G,B,A per pixel. */\n readonly data: Uint8Array;\n}\n\nexport interface RenderImageOptions {\n /**\n * Target raster width in pixels. Height follows the slide's aspect ratio.\n * Defaults to 1280 (matches the fidelity harness baseline).\n */\n readonly width?: number;\n /**\n * Text measurer used for wrap/positioning. Defaults to a shared fontkit\n * measurer over the bundled fonts. Pass {@link buildFontkitMeasurer}'s\n * result to control its lifetime, or a custom measurer to swap fonts.\n */\n readonly measureText?: TextMeasurer;\n /**\n * Extra font files (ttf / otf / ttc paths) loaded into the rasterizer in\n * addition to the bundled Latin faces. The bundled set has no CJK\n * coverage, so decks with Japanese / Chinese / Korean text render as\n * missing-glyph boxes unless the caller supplies a face here (pair it with\n * a {@link buildFontkitMeasurer} `fonts` registration so wrap math uses the\n * same metrics). East Asian text is drawn in the family of the first face\n * here that covers kana, in the weight closest to the run's, so supply that\n * family's bold face too for bold Japanese text.\n */\n readonly fontFiles?: ReadonlyArray<string>;\n /**\n * Also let the rasterizer discover OS-installed fonts as a fallback for\n * families the bundled + `fontFiles` faces don't cover. Defaults to\n * `false`: system fonts vary per machine, so the default keeps output\n * deterministic.\n */\n readonly loadSystemFonts?: boolean;\n}\n\nconst DEFAULT_WIDTH = 1280;\n\n// The fontkit measurer reads + verifies every bundled face on construction.\n// Build it once and reuse across calls; do it lazily so importing this module\n// (e.g. just for the types) doesn't touch the filesystem.\nlet sharedMeasurer: TextMeasurer | undefined;\nconst getSharedMeasurer = (): TextMeasurer => (sharedMeasurer ??= buildFontkitMeasurer());\n\n// Hiragana あ: a face that has it is a Japanese (or pan-CJK) face.\nconst KANA_PROBE = 0x3042;\n\n// Reading a face's names means parsing the file, so remember the answer per\n// list of files: a deck renders every slide with the same list.\nconst eastAsianFamilyCache = new Map<string, string | undefined>();\n\n// The family resvg matches a face by: the typographic family when the face\n// has one (\"Hiragino Sans\" for \"Hiragino Sans W6\"), else the family name.\nconst matchFamilyOf = (font: fontkit.Font): string =>\n font.getName('preferredFamily', 'en') ?? font.familyName;\n\n// resvg skips a font file it cannot find or parse, and `fontFiles` promises\n// the same, so such a file names no family here either.\nconst openFontFile = (path: string): fontkit.Font | fontkit.FontCollection | null => {\n if (!existsSync(path)) return null;\n try {\n return fontkit.create(readFileSync(path));\n } catch {\n return null;\n }\n};\n\nconst eastAsianFamilyOf = (fontFiles: ReadonlyArray<string>): string | undefined => {\n const key = fontFiles.join('\\n');\n if (eastAsianFamilyCache.has(key)) return eastAsianFamilyCache.get(key);\n let family: string | undefined;\n for (const path of fontFiles) {\n const opened = openFontFile(path);\n if (opened === null) continue;\n const faces = 'fonts' in opened ? opened.fonts : [opened];\n const face = faces.find((f) => f.hasGlyphForCodePoint(KANA_PROBE));\n if (face) {\n family = matchFamilyOf(face);\n break;\n }\n }\n eastAsianFamilyCache.set(key, family);\n return family;\n};\n\nconst rasterize = (pres: PresentationData, slide: SlideData, opts: RenderImageOptions) => {\n // `getSlideSize` is only consulted to fail fast on a malformed deck; the SVG\n // carries its own viewBox and resvg fits to the requested width.\n getSlideSize(pres);\n const width = opts.width ?? DEFAULT_WIDTH;\n const measureText = opts.measureText ?? getSharedMeasurer();\n // resvg's own fallback for glyphs the run's family lacks ignores the weight\n // (bold Japanese paints with the regular face), so name the face instead.\n const eastAsianFamily = eastAsianFamilyOf(opts.fontFiles ?? []);\n const svg = renderSlideSvg(pres, slide, {\n measureText,\n textLayout: 'svg',\n ...(eastAsianFamily === undefined ? {} : { eastAsianFamily }),\n });\n const resvg = new Resvg(svg, {\n fitTo: { mode: 'width', value: width },\n font: {\n loadSystemFonts: opts.loadSystemFonts ?? false, // default: deterministic bundled set\n fontFiles: [...FONT_FILES, ...(opts.fontFiles ?? [])],\n defaultFontFamily: SANS,\n sansSerifFamily: SANS,\n serifFamily: SERIF,\n monospaceFamily: MONO,\n },\n });\n return resvg.render();\n};\n\n/** Render one slide to PNG-encoded bytes. */\nexport const renderSlideToImage = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): Uint8Array => new Uint8Array(rasterize(pres, slide, opts).asPng());\n\n/**\n * Render one slide to raw RGBA pixels plus the PNG encoding of the same frame.\n * Both come from a single rasterization, so callers needing pixel access (SSIM,\n * diffing) and a saveable file don't pay to render twice.\n */\nexport const renderSlideToRgba = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): { readonly image: RgbaImage; readonly png: Uint8Array } => {\n const rendered = rasterize(pres, slide, opts);\n return {\n image: {\n width: rendered.width,\n height: rendered.height,\n data: new Uint8Array(rendered.pixels),\n },\n png: new Uint8Array(rendered.asPng()),\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"node.js","names":[],"sources":["../src/measure.ts","../src/node.ts"],"sourcesContent":["// Node-only fontkit-backed TextMeasurer. NEVER imported by render-slide.ts /\n// text-layout.ts (which must stay browser-safe) — only by the node entry. It\n// measures advance widths and vertical metrics from the same bundled TTFs that\n// resvg rasterizes with (and that LibreOffice renders ground truth with), so\n// the engine's wrap/positioning math agrees with the painted pixels.\n\nimport * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport {\n ARIAL,\n AVG_GLYPH_W_RATIO,\n isCjk,\n MONO,\n SANS,\n SERIF,\n substituteFamily,\n TIMES,\n type FontSpec,\n type MeasureResult,\n type TextMeasurer,\n} from './text-layout.ts';\n\nexport const FONT_DIR = fileURLToPath(new URL('../fonts/', import.meta.url));\n\n// Internal family name (what the emitter writes + resvg matches) → file prefix.\nconst FAMILY_TO_PREFIX: Record<string, string> = {\n [SANS]: 'Carlito',\n [SERIF]: 'Caladea',\n [ARIAL]: 'LiberationSans',\n [TIMES]: 'LiberationSerif',\n [MONO]: 'LiberationMono',\n};\n\nconst STYLES = ['Regular', 'Bold', 'Italic', 'BoldItalic'] as const;\n\nconst facePath = (prefix: string, style: string): string => `${FONT_DIR}${prefix}-${style}.ttf`;\n\n/** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */\nexport const FONT_FILES: string[] = Object.values(FAMILY_TO_PREFIX).flatMap((prefix) =>\n STYLES.map((s) => facePath(prefix, s)),\n);\n\n// fontkit.create returns Font | FontCollection; our TTFs are single fonts.\nconst openFont = (path: string): fontkit.Font => {\n const font = fontkit.create(readFileSync(path));\n if (!('layout' in font)) {\n throw new Error(`Expected a single font in ${path}, got a collection`);\n }\n return font;\n};\n\nconst styleSuffix = (spec: { bold: boolean; italic: boolean }): (typeof STYLES)[number] => {\n if (spec.bold && spec.italic) return 'BoldItalic';\n if (spec.bold) return 'Bold';\n if (spec.italic) return 'Italic';\n return 'Regular';\n};\n\n/** A font the caller supplies for measurement, keyed by the AUTHORED family\n * name (`<a:latin typeface>` / `<a:ea typeface>` — e.g. \"游ゴシック\",\n * \"Montserrat\"), which routinely differs from the file's internal name. */\nexport interface RegisteredFont {\n readonly family: string;\n /** Path to a ttf/otf file, or its bytes. */\n readonly source: string | Uint8Array;\n readonly bold?: boolean;\n readonly italic?: boolean;\n}\n\nexport interface FontkitMeasurerOptions {\n /** Fonts the deck actually uses. A run whose family matches a registered\n * font measures with it; any registered font also serves as a fallback\n * for glyphs the resolved font lacks (the bundled faces are Latin-only,\n * so this is how CJK text gets real metrics). */\n readonly fonts?: ReadonlyArray<RegisteredFont>;\n}\n\nconst openFontSource = (source: string | Uint8Array): fontkit.Font => {\n if (typeof source === 'string') return openFont(source);\n const font = fontkit.create(Buffer.from(source));\n if (!('layout' in font)) {\n throw new Error('Expected a single font, got a collection');\n }\n return font;\n};\n\n// Per-character width estimate for glyphs no available font covers — the same\n// ratios as the heuristic measurer, so estimates and defaultMeasurer agree.\nconst estimateCharWidth = (ch: string, sizePx: number): number =>\n sizePx * (isCjk(ch.codePointAt(0) ?? 0) ? 1 : AVG_GLYPH_W_RATIO);\n\nexport const buildFontkitMeasurer = (options: FontkitMeasurerOptions = {}): TextMeasurer => {\n const cache = new Map<string, fontkit.Font>();\n // Load + verify every face up front. The familyName assertion guarantees the\n // emit-side family === resvg match === the face we measure, which is the load-\n // bearing invariant for \"x= is where glyphs land\".\n for (const [family, prefix] of Object.entries(FAMILY_TO_PREFIX)) {\n for (const style of STYLES) {\n const path = facePath(prefix, style);\n if (!existsSync(path)) throw new Error(`Missing bundled font: ${path}`);\n const font = openFont(path);\n if (style === 'Regular' && font.familyName !== family) {\n throw new Error(\n `Font family mismatch: ${path} reports \"${font.familyName}\", expected \"${family}\". ` +\n `The substitution map and resvg matching rely on this name.`,\n );\n }\n cache.set(`${prefix}-${style}`, font);\n }\n }\n\n // Registered fonts, keyed by lowercased authored family + style. No\n // familyName assertion here: the registration key is the DECK's name for\n // the font, which legitimately differs from the file's internal name.\n const registered = new Map<string, fontkit.Font>();\n const fallbackFonts: fontkit.Font[] = [];\n for (const reg of options.fonts ?? []) {\n const font = openFontSource(reg.source);\n const style = styleSuffix({ bold: reg.bold ?? false, italic: reg.italic ?? false });\n registered.set(`${reg.family.toLowerCase()}-${style}`, font);\n fallbackFonts.push(font);\n }\n\n const pick = (spec: FontSpec): fontkit.Font => {\n const style = styleSuffix(spec);\n const regKey = spec.family.toLowerCase();\n const reg = registered.get(`${regKey}-${style}`) ?? registered.get(`${regKey}-Regular`);\n if (reg) return reg;\n // The render path pre-substitutes families (spec.family is a bundled\n // internal name); the audit path passes the authored name through, so\n // resolve it with the same map before keying the bundled faces.\n const prefix =\n FAMILY_TO_PREFIX[spec.family] ?? FAMILY_TO_PREFIX[substituteFamily(spec.family)]!;\n return (\n cache.get(`${prefix}-${style}`) ??\n cache.get(`${prefix}-Regular`) ??\n cache.get('Carlito-Regular')!\n );\n };\n\n // Splits `text` into maximal segments coverable by one font: the resolved\n // font first, then each registered font in registration order. Characters\n // no font covers fall back to the per-character estimate (approximate).\n const measureWithFallback = (\n text: string,\n spec: FontSpec,\n primary: fontkit.Font,\n ): MeasureResult => {\n const chain = [primary, ...fallbackFonts.filter((f) => f !== primary)];\n let widthPx = 0;\n let approximate = false;\n let metricsFont: fontkit.Font | null = null;\n // Keep the bounds signed until the end. Initialising either side at zero\n // would turn a glyph entirely above/below the baseline into a false ink\n // extent on the opposite side.\n let inkTop = Number.NEGATIVE_INFINITY;\n let inkBottom = Number.POSITIVE_INFINITY;\n let segment = '';\n let segmentFont: fontkit.Font | null = null;\n const flush = (): void => {\n if (segment === '' || segmentFont === null) return;\n const scale = spec.sizePx / segmentFont.unitsPerEm;\n const run = segmentFont.layout(segment, { kern: spec.kerning !== false });\n widthPx += run.advanceWidth * scale;\n let penY = 0;\n for (const [index, glyph] of run.glyphs.entries()) {\n const box = glyph.bbox;\n if (\n !box ||\n !Number.isFinite(box.minY) ||\n !Number.isFinite(box.maxY) ||\n box.maxY <= box.minY\n ) {\n penY += run.positions[index]?.yAdvance ?? 0;\n continue;\n }\n // fontkit reports the glyph bbox in font units and shaping can move a\n // glyph vertically (combining marks are the common case). Include the\n // shaped position so the result describes the painted glyph, rather\n // than the unpositioned outline.\n const position = run.positions[index];\n const glyphY = penY + (position?.yOffset ?? 0);\n inkTop = Math.max(inkTop, (box.maxY + glyphY) * scale);\n inkBottom = Math.min(inkBottom, (box.minY + glyphY) * scale);\n penY += position?.yAdvance ?? 0;\n }\n metricsFont ??= segmentFont;\n segment = '';\n };\n for (const ch of text) {\n const cp = ch.codePointAt(0) ?? 0;\n const font = chain.find((f) => f.hasGlyphForCodePoint(cp)) ?? null;\n if (font !== segmentFont) {\n flush();\n segmentFont = font;\n }\n if (font === null) {\n widthPx += estimateCharWidth(ch, spec.sizePx);\n approximate = true;\n } else {\n segment += ch;\n }\n }\n flush();\n const vm = verticalMetrics(metricsFont ?? primary);\n const vmScale = spec.sizePx / (metricsFont ?? primary).unitsPerEm;\n const glyphCount = [...text].length;\n const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;\n return {\n widthPx: widthPx + tracking,\n ascentPx: vm.ascent * vmScale,\n descentPx: vm.descent * vmScale,\n lineGapPx: vm.lineGap * vmScale,\n ...(!approximate && metricsFont !== null\n ? {\n inkAscentPx: inkTop === Number.NEGATIVE_INFINITY ? 0 : inkTop,\n inkDescentPx:\n inkBottom === Number.POSITIVE_INFINITY || inkBottom === 0 ? 0 : -inkBottom,\n }\n : {}),\n ...(approximate ? { approximate } : {}),\n };\n };\n\n return (text, spec) => measureWithFallback(text, spec, pick(spec));\n};\n\n// Which vertical-metric set a renderer uses depends on the OS/2 fsSelection\n// USE_TYPO_METRICS bit. When it's clear (the common case, incl. Carlito /\n// Liberation), GDI / LibreOffice place the baseline at usWinAscent and size the\n// line box as usWinAscent + usWinDescent (no extra gap). When set, they use the\n// sTypo* metrics plus typoLineGap. fontkit's `.ascent`/`.descent` expose the\n// hhea values, which for these fonts differ from usWinAscent by ~12px at 44pt —\n// exactly the baseline offset that otherwise mismatches ground truth. Returns\n// font-unit values; the caller scales to px.\ninterface VMetrics {\n readonly ascent: number;\n readonly descent: number;\n readonly lineGap: number;\n}\nconst verticalMetrics = (font: fontkit.Font): VMetrics => {\n const os2: unknown = (font as { 'OS/2'?: unknown })['OS/2'];\n if (os2 && typeof os2 === 'object') {\n const o = os2 as {\n fsSelection?: { useTypoMetrics?: boolean };\n typoAscender?: number;\n typoDescender?: number;\n typoLineGap?: number;\n winAscent?: number;\n winDescent?: number;\n };\n if (o.fsSelection?.useTypoMetrics && o.typoAscender !== undefined) {\n return {\n ascent: o.typoAscender,\n descent: Math.abs(o.typoDescender ?? 0),\n lineGap: o.typoLineGap ?? 0,\n };\n }\n if (o.winAscent !== undefined && o.winDescent !== undefined) {\n return { ascent: o.winAscent, descent: Math.abs(o.winDescent), lineGap: 0 };\n }\n }\n // Fall back to hhea metrics if OS/2 is absent.\n return { ascent: font.ascent, descent: Math.abs(font.descent), lineGap: font.lineGap };\n};\n","// `@office-kit/pptx-preview/node` — Node entry: rasterize a slide to a PNG or raw\n// RGBA image, with no browser binary.\n//\n// Pipeline: `renderSlideToSvg(..., { textLayout: 'svg', measureText })` lays\n// text out as pure `<text>`, then resvg paints the SVG to pixels. The fontkit\n// measurer, resvg's `fontFiles`, and the SVG's family names all reference the\n// SAME bundled fonts, so the wrap/positioning math agrees with the rasterized\n// glyphs.\n\nimport { Resvg } from '@resvg/resvg-js';\nimport * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { getSlideSize, type PresentationData, type SlideData } from '@office-kit/pptx';\nimport { renderSlideSvg } from './render-slide.ts';\nimport { MONO, SANS, SERIF, type RenderSlideOptions, type TextMeasurer } from './text-layout.ts';\nimport { buildFontkitMeasurer, FONT_FILES } from './measure.ts';\n\n// Re-export the browser-safe surface so a Node consumer gets everything from\n// one import.\nexport * from './index.ts';\n\n// Advanced: pre-build / share a fontkit measurer, or locate the bundled fonts.\nexport { buildFontkitMeasurer, FONT_FILES, FONT_DIR } from './measure.ts';\nexport type { FontkitMeasurerOptions, RegisteredFont } from './measure.ts';\n\n/** Raw, un-encoded raster: RGBA bytes in row-major order. */\nexport interface RgbaImage {\n readonly width: number;\n readonly height: number;\n /** `width * height * 4` bytes, R,G,B,A per pixel. */\n readonly data: Uint8Array;\n}\n\nexport interface RenderImageOptions {\n /**\n * Target raster width in pixels. Height follows the slide's aspect ratio.\n * Defaults to 1280 (matches the fidelity harness baseline).\n */\n readonly width?: number;\n /**\n * Text measurer used for wrap/positioning. Defaults to a shared fontkit\n * measurer over the bundled fonts. Pass {@link buildFontkitMeasurer}'s\n * result to control its lifetime, or a custom measurer to swap fonts.\n */\n readonly measureText?: TextMeasurer;\n /**\n * Extra font files (ttf / otf / ttc paths) loaded into the rasterizer in\n * addition to the bundled Latin faces. The bundled set has no CJK\n * coverage, so decks with Japanese / Chinese / Korean text render as\n * missing-glyph boxes unless the caller supplies a face here (pair it with\n * a {@link buildFontkitMeasurer} `fonts` registration so wrap math uses the\n * same metrics). East Asian text is drawn in the family of the first face\n * here that covers kana, in the weight closest to the run's, so supply that\n * family's bold face too for bold Japanese text.\n */\n readonly fontFiles?: ReadonlyArray<string>;\n /**\n * Also let the rasterizer discover OS-installed fonts as a fallback for\n * families the bundled + `fontFiles` faces don't cover. Defaults to\n * `false`: system fonts vary per machine, so the default keeps output\n * deterministic.\n */\n readonly loadSystemFonts?: boolean;\n /**\n * Maps an authored font name to the family drawn and measured, as\n * {@link RenderSlideOptions.resolveFamily}. Defaults to `substituteFamily`.\n * Return a family loaded through `fontFiles` (and registered with the\n * measurer) to draw a deck's own font instead of a bundled substitute.\n * East Asian characters still take the family of the first `fontFiles`\n * face that covers kana; this only decides the rest of the run.\n */\n readonly resolveFamily?: RenderSlideOptions['resolveFamily'];\n}\n\nconst DEFAULT_WIDTH = 1280;\n\n// The fontkit measurer reads + verifies every bundled face on construction.\n// Build it once and reuse across calls; do it lazily so importing this module\n// (e.g. just for the types) doesn't touch the filesystem.\nlet sharedMeasurer: TextMeasurer | undefined;\nconst getSharedMeasurer = (): TextMeasurer => (sharedMeasurer ??= buildFontkitMeasurer());\n\n// Hiragana あ: a face that has it is a Japanese (or pan-CJK) face.\nconst KANA_PROBE = 0x3042;\n\n// Reading a face's names means parsing the file, so remember the answer per\n// list of files: a deck renders every slide with the same list.\nconst eastAsianFamilyCache = new Map<string, string | undefined>();\n\n// The family resvg matches a face by: the typographic family when the face\n// has one (\"Hiragino Sans\" for \"Hiragino Sans W6\"), else the family name.\nconst matchFamilyOf = (font: fontkit.Font): string =>\n font.getName('preferredFamily', 'en') ?? font.familyName;\n\n// resvg skips a font file it cannot find or parse, and `fontFiles` promises\n// the same, so such a file names no family here either.\nconst openFontFile = (path: string): fontkit.Font | fontkit.FontCollection | null => {\n if (!existsSync(path)) return null;\n try {\n return fontkit.create(readFileSync(path));\n } catch {\n return null;\n }\n};\n\nconst eastAsianFamilyOf = (fontFiles: ReadonlyArray<string>): string | undefined => {\n const key = fontFiles.join('\\n');\n if (eastAsianFamilyCache.has(key)) return eastAsianFamilyCache.get(key);\n let family: string | undefined;\n for (const path of fontFiles) {\n const opened = openFontFile(path);\n if (opened === null) continue;\n const faces = 'fonts' in opened ? opened.fonts : [opened];\n const face = faces.find((f) => f.hasGlyphForCodePoint(KANA_PROBE));\n if (face) {\n family = matchFamilyOf(face);\n break;\n }\n }\n eastAsianFamilyCache.set(key, family);\n return family;\n};\n\nconst rasterize = (pres: PresentationData, slide: SlideData, opts: RenderImageOptions) => {\n // `getSlideSize` is only consulted to fail fast on a malformed deck; the SVG\n // carries its own viewBox and resvg fits to the requested width.\n getSlideSize(pres);\n const width = opts.width ?? DEFAULT_WIDTH;\n const measureText = opts.measureText ?? getSharedMeasurer();\n // resvg's own fallback for glyphs the run's family lacks ignores the weight\n // (bold Japanese paints with the regular face), so name the face instead.\n const eastAsianFamily = eastAsianFamilyOf(opts.fontFiles ?? []);\n const svg = renderSlideSvg(pres, slide, {\n measureText,\n textLayout: 'svg',\n ...(opts.resolveFamily === undefined ? {} : { resolveFamily: opts.resolveFamily }),\n ...(eastAsianFamily === undefined ? {} : { eastAsianFamily }),\n });\n const resvg = new Resvg(svg, {\n fitTo: { mode: 'width', value: width },\n font: {\n loadSystemFonts: opts.loadSystemFonts ?? false, // default: deterministic bundled set\n fontFiles: [...FONT_FILES, ...(opts.fontFiles ?? [])],\n defaultFontFamily: SANS,\n sansSerifFamily: SANS,\n serifFamily: SERIF,\n monospaceFamily: MONO,\n },\n });\n return resvg.render();\n};\n\n/** Render one slide to PNG-encoded bytes. */\nexport const renderSlideToImage = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): Uint8Array => new Uint8Array(rasterize(pres, slide, opts).asPng());\n\n/**\n * Render one slide to raw RGBA pixels plus the PNG encoding of the same frame.\n * Both come from a single rasterization, so callers needing pixel access (SSIM,\n * diffing) and a saveable file don't pay to render twice.\n */\nexport const renderSlideToRgba = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): { readonly image: RgbaImage; readonly png: Uint8Array } => {\n const rendered = rasterize(pres, slide, opts);\n return {\n image: {\n width: rendered.width,\n height: rendered.height,\n data: new Uint8Array(rendered.pixels),\n },\n png: new Uint8Array(rendered.asPng()),\n };\n};\n"],"mappings":"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"}
|
|
@@ -3509,17 +3509,22 @@ const chartText = (f, x, y, attrs, content) => {
|
|
|
3509
3509
|
}
|
|
3510
3510
|
return `<text x="${px(x)}" y="${px(y)}" ${attrs}>${content}</text>`;
|
|
3511
3511
|
};
|
|
3512
|
-
const
|
|
3512
|
+
const CHART_AREA_INSET_RATIO = .02;
|
|
3513
|
+
const PX_PER_MM = 96 / 25.4;
|
|
3514
|
+
const DEFAULT_LEGEND_PT = 11;
|
|
3515
|
+
const CATEGORY_LABEL_OFFSET_PX = 12;
|
|
3516
|
+
const layoutChart = (xEmu, yEmu, wEmu, hEmu, hasTitle, hasAxes, titleOverlay = false, legendOverlay = false, hasLegend = true, titlePx = DEFAULT_CHART_TITLE_PT * PX_PER_PT, plotAreaLayout, yAxisGutter = hasAxes ? DEFAULT_Y_AXIS_GUTTER : 0, xAxisGutter = hasAxes ? BAR_VALUE_LABEL_ROW : 0, proportionalInset = false) => {
|
|
3513
3517
|
const x = xEmu / EMU_PER_PX;
|
|
3514
3518
|
const y = yEmu / EMU_PER_PX;
|
|
3515
3519
|
const w = wEmu / EMU_PER_PX;
|
|
3516
3520
|
const h = hEmu / EMU_PER_PX;
|
|
3517
3521
|
const titleStrip = hasTitle && !titleOverlay ? Math.round(titlePx * 2.4) : 0;
|
|
3518
3522
|
const legendStrip = hasLegend && !legendOverlay ? 18 : 0;
|
|
3519
|
-
const
|
|
3523
|
+
const paddingX = proportionalInset ? w * CHART_AREA_INSET_RATIO : 8;
|
|
3524
|
+
const paddingY = proportionalInset ? h * CHART_AREA_INSET_RATIO : 8;
|
|
3520
3525
|
const inner = plotAreaLayout?.target === "inner" ? plotAreaLayout : void 0;
|
|
3521
|
-
const plotX = inner ? x + Math.max(0, Math.min(1, inner.x)) * w : x +
|
|
3522
|
-
const plotY = inner ? y + Math.max(0, Math.min(1, inner.y)) * h : y + titleStrip +
|
|
3526
|
+
const plotX = inner ? x + Math.max(0, Math.min(1, inner.x)) * w : x + paddingX + yAxisGutter;
|
|
3527
|
+
const plotY = inner ? y + Math.max(0, Math.min(1, inner.y)) * h : y + titleStrip + paddingY;
|
|
3523
3528
|
return {
|
|
3524
3529
|
x,
|
|
3525
3530
|
y,
|
|
@@ -3527,9 +3532,9 @@ const layoutChart = (xEmu, yEmu, wEmu, hEmu, hasTitle, hasAxes, titleOverlay = f
|
|
|
3527
3532
|
h,
|
|
3528
3533
|
plotX,
|
|
3529
3534
|
plotY,
|
|
3530
|
-
plotW: inner ? Math.max(0, Math.min(x + w, x + (inner.x + inner.w) * w) - plotX) : Math.max(0, w - 2 *
|
|
3531
|
-
plotH: inner ? Math.max(0, Math.min(y + h, y + (inner.y + inner.h) * h) - plotY) : Math.max(0, h - titleStrip - legendStrip - xAxisGutter - 2 *
|
|
3532
|
-
titleY: y + (titleOverlay ? 14 :
|
|
3535
|
+
plotW: inner ? Math.max(0, Math.min(x + w, x + (inner.x + inner.w) * w) - plotX) : Math.max(0, w - 2 * paddingX - yAxisGutter),
|
|
3536
|
+
plotH: inner ? Math.max(0, Math.min(y + h, y + (inner.y + inner.h) * h) - plotY) : Math.max(0, h - titleStrip - legendStrip - xAxisGutter - 2 * paddingY),
|
|
3537
|
+
titleY: y + (titleOverlay ? 14 : paddingY + titlePx * .7),
|
|
3533
3538
|
legendY: y + h - (legendOverlay ? 18 : legendStrip / 2)
|
|
3534
3539
|
};
|
|
3535
3540
|
};
|
|
@@ -3695,7 +3700,7 @@ const renderValueAxis = (f, axis) => {
|
|
|
3695
3700
|
const ty2 = tickMark === "out" ? baseY : tickMark === "cross" ? baseY - tickLen : baseY - tickLen;
|
|
3696
3701
|
out.push(`<line x1="${px(xp)}" y1="${px(ty1)}" x2="${px(xp)}" y2="${px(ty2)}" stroke="${axisColor}" stroke-width="1"/>`);
|
|
3697
3702
|
}
|
|
3698
|
-
const horizLabelY = f.plotY + f.plotH +
|
|
3703
|
+
const horizLabelY = f.plotY + f.plotH + CATEGORY_LABEL_OFFSET_PX;
|
|
3699
3704
|
const rotH = axis.labelRotationDeg ?? 0;
|
|
3700
3705
|
const transformH = rotH ? ` transform="rotate(${rotH} ${px(xp)} ${px(horizLabelY)})"` : "";
|
|
3701
3706
|
out.push(chartText(f, xp, horizLabelY, `text-anchor="middle" dominant-baseline="middle" ${axisTickAttrs(axis.labelStyle)}${transformH}`, `${escapeXml(fmtTick(t))}`));
|
|
@@ -3787,7 +3792,7 @@ const renderCategoryAxis = (f, orientation, cats, pointCount, skip = 1, labelSty
|
|
|
3787
3792
|
for (let i = 0; i < pointCount; i++) {
|
|
3788
3793
|
if (skip > 1 && i % skip !== 0) continue;
|
|
3789
3794
|
const cx = f.plotX + (i + .5) * step;
|
|
3790
|
-
const cy = f.plotY + f.plotH +
|
|
3795
|
+
const cy = f.plotY + f.plotH + CATEGORY_LABEL_OFFSET_PX;
|
|
3791
3796
|
const truncated = labels[i] !== void 0 && labels[i].length > truncLen ? `${labels[i].slice(0, truncLen - 2)}…` : labels[i] ?? "";
|
|
3792
3797
|
const transform = labelRotationDeg && labelRotationDeg !== 0 ? ` transform="rotate(${labelRotationDeg} ${px(cx)} ${px(cy)})"` : "";
|
|
3793
3798
|
const anchor = labelAlign === "l" ? "start" : labelAlign === "r" ? "end" : labelAlign === "ctr" ? "middle" : labelRotationDeg && labelRotationDeg > 0 ? "end" : labelRotationDeg && labelRotationDeg < 0 ? "start" : "middle";
|
|
@@ -3903,7 +3908,7 @@ const approxLegendTextPx = (text, fontPx) => {
|
|
|
3903
3908
|
};
|
|
3904
3909
|
const renderChartLegend = (f, names, colors, position = "b", textStyle, markers) => {
|
|
3905
3910
|
if (names.length === 0) return "";
|
|
3906
|
-
const sz = textStyle?.sizePt ??
|
|
3911
|
+
const sz = textStyle?.sizePt ?? DEFAULT_LEGEND_PT;
|
|
3907
3912
|
const fill = textStyle?.color ?? "#374151";
|
|
3908
3913
|
const weight = textStyle?.bold ? " font-weight=\"600\"" : "";
|
|
3909
3914
|
const italic = textStyle?.italic ? " font-style=\"italic\"" : "";
|
|
@@ -4237,8 +4242,15 @@ const formatDataLabelValue = (spec, seriesIdx, v, pointIdx) => {
|
|
|
4237
4242
|
const nf = (pointIdx === void 0 ? void 0 : spec.series[seriesIdx]?.pointDataLabels?.[pointIdx])?.numberFormat ?? spec.series[seriesIdx]?.dataLabels?.numberFormat ?? spec.dataLabels?.numberFormat;
|
|
4238
4243
|
return nf ? formatAxisLabel(v, nf) : formatChartValue(v);
|
|
4239
4244
|
};
|
|
4245
|
+
const LINE_DATA_LABEL_PT = 9;
|
|
4246
|
+
const DEFAULT_MARKER_PT = 5;
|
|
4247
|
+
const LABEL_ASCENT_RATIO = .952;
|
|
4248
|
+
const LABEL_DESCENT_RATIO = .269;
|
|
4249
|
+
const dataLabelStyle = (spec, seriesIdx, pointIdx) => {
|
|
4250
|
+
return (pointIdx === void 0 ? void 0 : spec.series[seriesIdx]?.pointDataLabels?.[pointIdx])?.textStyle ?? spec.series[seriesIdx]?.dataLabels?.textStyle ?? spec.dataLabels?.textStyle;
|
|
4251
|
+
};
|
|
4240
4252
|
const dataLabelTextAttrs = (spec, seriesIdx, fallbackFill, fallbackSizePt = 9, fallbackBold = false, pointIdx) => {
|
|
4241
|
-
const style = (
|
|
4253
|
+
const style = dataLabelStyle(spec, seriesIdx, pointIdx);
|
|
4242
4254
|
const sz = style?.sizePt ?? fallbackSizePt;
|
|
4243
4255
|
const fill = style?.color ?? fallbackFill;
|
|
4244
4256
|
const weight = style?.bold ?? fallbackBold ? " font-weight=\"600\"" : "";
|
|
@@ -4397,9 +4409,11 @@ const renderLineChart = (f, spec, colors, fill) => {
|
|
|
4397
4409
|
})() : "";
|
|
4398
4410
|
out.push(`<path d="${dPath}" fill="none" stroke="${series.lineColor ?? color}" stroke-width="${lineWPx.toFixed(2)}" stroke-linejoin="round" stroke-linecap="round"${dashAttr}/>`);
|
|
4399
4411
|
const explicitSymbol = series.markerSymbol !== void 0 && series.markerSymbol !== "auto" && series.markerSymbol !== "none";
|
|
4400
|
-
|
|
4412
|
+
const markerShown = series.markerSymbol !== "none" && !fill && (spec.lineMarkers === true || explicitSymbol);
|
|
4413
|
+
const markerHalfPx = (series.markerSizePt ?? DEFAULT_MARKER_PT) * PX_PER_PT / 2;
|
|
4414
|
+
if (markerShown) {
|
|
4401
4415
|
const symbol = autoMarkerSymbol(series.markerSymbol, s);
|
|
4402
|
-
const size = series.markerSizePt ??
|
|
4416
|
+
const size = series.markerSizePt ?? DEFAULT_MARKER_PT;
|
|
4403
4417
|
const r = Math.max(1, size * .5);
|
|
4404
4418
|
for (const [xp, yp] of pts) out.push(seriesMarker(symbol, xp, yp, r, ...markerColors(series, color)));
|
|
4405
4419
|
}
|
|
@@ -4413,9 +4427,11 @@ const renderLineChart = (f, spec, colors, fill) => {
|
|
|
4413
4427
|
const [xp, yp] = p;
|
|
4414
4428
|
const pos = chartPointLabelOptions(spec, s, c).position;
|
|
4415
4429
|
const lx = pos === "l" ? xp - 6 : pos === "r" ? xp + 6 : xp;
|
|
4416
|
-
const
|
|
4430
|
+
const labelPx = (dataLabelStyle(spec, s, c)?.sizePt ?? LINE_DATA_LABEL_PT) * PX_PER_PT;
|
|
4431
|
+
const clearance = (markerShown ? markerHalfPx : 0) + PX_PER_MM;
|
|
4432
|
+
const ly = pos === "b" ? yp + clearance + labelPx * LABEL_ASCENT_RATIO : pos === "ctr" || pos === "l" || pos === "r" ? yp + 3 : yp - clearance - labelPx * LABEL_DESCENT_RATIO;
|
|
4417
4433
|
const anchor = pos === "l" ? "end" : pos === "r" ? "start" : "middle";
|
|
4418
|
-
out.push(chartText(f, lx, ly, `text-anchor="${anchor}" ${dataLabelTextAttrs(spec, s, "#374151",
|
|
4434
|
+
out.push(chartText(f, lx, ly, `text-anchor="${anchor}" ${dataLabelTextAttrs(spec, s, "#374151", LINE_DATA_LABEL_PT, false, c)}`, `${escapeXml(labelText)}`));
|
|
4419
4435
|
}
|
|
4420
4436
|
if (!isStacked && series.trendline) {
|
|
4421
4437
|
const finiteXs = [];
|
|
@@ -4758,10 +4774,13 @@ const renderChart = (shape, x, y, w, h, transform, theme, groupReflected, measur
|
|
|
4758
4774
|
const barLabels = spec.kind === "bar" && !spec.categoryAxisHidden && spec.categoryAxisTickLabelPos !== "none" && !spec.categoryAxisLabelRotationDeg ? layoutBarCategoryLabels(spec, w / EMU_PER_PX, measure) : void 0;
|
|
4759
4775
|
const barValueTitleRow = spec.kind === "bar" && spec.valueAxisTitle ? axisTitleRowPx(spec.valueAxisTitleStyle) : 0;
|
|
4760
4776
|
const barCategoryTitleRow = spec.kind === "bar" && spec.categoryAxisTitle ? axisTitleRowPx(spec.categoryAxisTitleStyle) : 0;
|
|
4777
|
+
const vertical = isCartesian && spec.kind !== "bar";
|
|
4778
|
+
const categoryLabelsShown = !spec.categoryAxisHidden && spec.categoryAxisTickLabelPos !== "none";
|
|
4779
|
+
const categoryLabelRow = vertical && categoryLabelsShown ? Math.max(BAR_VALUE_LABEL_ROW, CATEGORY_LABEL_OFFSET_PX + categoryLabelFontPx(spec.categoryAxisLabelStyle) * CATEGORY_LABEL_LINE_HEIGHT / 2) : void 0;
|
|
4761
4780
|
const flip = getShapeFlip(shape);
|
|
4762
4781
|
const f = {
|
|
4763
4782
|
reflected: groupReflected !== Boolean(flip && flip.horizontal !== flip.vertical),
|
|
4764
|
-
...layoutChart(x, y, w, h, !!spec.title, hasAxes, spec.titleOverlay ?? false, spec.legend?.overlay ?? false, hasLegend, (spec.titleStyle?.sizePt ?? DEFAULT_CHART_TITLE_PT) * PX_PER_PT, spec.plotAreaLayout, barLabels ? Math.max(DEFAULT_Y_AXIS_GUTTER, barLabels.widthPx + CATEGORY_LABEL_GAP) + barCategoryTitleRow : void 0, barValueTitleRow > 0 ? BAR_VALUE_LABEL_ROW + barValueTitleRow :
|
|
4783
|
+
...layoutChart(x, y, w, h, !!spec.title, hasAxes, spec.titleOverlay ?? false, spec.legend?.overlay ?? false, hasLegend, (spec.titleStyle?.sizePt ?? DEFAULT_CHART_TITLE_PT) * PX_PER_PT, spec.plotAreaLayout, barLabels ? Math.max(DEFAULT_Y_AXIS_GUTTER, barLabels.widthPx + CATEGORY_LABEL_GAP) + barCategoryTitleRow : void 0, barValueTitleRow > 0 ? BAR_VALUE_LABEL_ROW + barValueTitleRow : categoryLabelRow, isCartesian)
|
|
4765
4784
|
};
|
|
4766
4785
|
const allNamesForLegend = spec.kind === "pie" || spec.kind === "doughnut" ? Array.from(spec.categories) : spec.series.map((s) => s.name);
|
|
4767
4786
|
const allColorsForLegend = spec.kind === "pie" || spec.kind === "doughnut" ? spec.categories.map((_, i) => spec.series[0]?.pointColors?.[i] ?? spec.series[0]?.color ?? colors[i % colors.length] ?? "#888") : spec.series.map((s, i) => s.color ?? colors[i % colors.length] ?? "#888");
|
|
@@ -6381,4 +6400,4 @@ function shapeTextAnchorOffset(pres, shape, options = {}) {
|
|
|
6381
6400
|
//#endregion
|
|
6382
6401
|
export { shapeTextRect as S, paragraphNumberLabels as _, renderSlideSvg as a, verticalTextStyle as b, AVG_GLYPH_W_RATIO as c, SERIF as d, TIMES as f, decimalSeparatorOf as g, substituteFamily as h, renderSlideLayoutSvg as i, MONO as l, isCjk as m, shapeTextAnchorOffset as n, renderTextEffectsSvg as o, defaultMeasurer as p, auditTextLayout as r, ARIAL as s, shapeAutoFitScale as t, SANS as u, textUnderlineStyle as v, resolveTextBodyRect as x, textColumnsStyle as y };
|
|
6383
6402
|
|
|
6384
|
-
//# sourceMappingURL=src-
|
|
6403
|
+
//# sourceMappingURL=src-DoFuEaap.js.map
|