From 014c3330b0291c4e5e1bbc90f8407bb0e90056cb Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 00:03:51 +0900 Subject: [PATCH 1/5] fix(preview): render EMF artwork and order gradient stops --- .changeset/emf-artwork-and-gradient-stops.md | 11 + packages/preview/README.md | 6 +- packages/preview/src/emf.ts | 276 +++++++++++++++++++ packages/preview/src/render-slide.ts | 13 +- test/preview-imported-artwork.test.ts | 183 ++++++++++++ 5 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 .changeset/emf-artwork-and-gradient-stops.md create mode 100644 packages/preview/src/emf.ts create mode 100644 test/preview-imported-artwork.test.ts diff --git a/.changeset/emf-artwork-and-gradient-stops.md b/.changeset/emf-artwork-and-gradient-stops.md new file mode 100644 index 00000000..5904252c --- /dev/null +++ b/.changeset/emf-artwork-and-gradient-stops.md @@ -0,0 +1,11 @@ +--- +'@office-kit/pptx-preview': patch +--- + +Fix missing EMF artwork and abrupt background color changes in imported slide previews. + +EMF pictures made of solid-filled line and Bézier paths now render as transparent +vector images, including artwork inherited from slide layouts. Images with +unsupported drawing commands retain their placeholder instead of rendering only +part of the artwork. Gradient colors are ordered by their positions before +rendering, so out-of-order stops no longer introduce flat bands or color jumps. diff --git a/packages/preview/README.md b/packages/preview/README.md index 03848101..6a704af1 100644 --- a/packages/preview/README.md +++ b/packages/preview/README.md @@ -125,8 +125,10 @@ placeholder layout/master cascade), strokes, rotation, effects (shadow, glow, soft edge, reflection), images with adjustments, charts (column, bar, line, area, pie, doughnut, scatter, radar, bubble), tables with per-run cell text, vertical and multi-column text in both text-layout modes, picture bullets, and -template (layout/master) decoration all render. SmartArt, animations, 3D, and -EMF/WMF fall back to labelled placeholders carrying a machine-readable marker +template (layout/master) decoration all render. EMF pictures containing solid-filled +line/Bézier paths, including rectangular and path clipping with copy semantics, +render as transparent SVG images. Other EMF drawing commands, WMF, SmartArt, +animations, and 3D fall back to labelled placeholders carrying a machine-readable marker (below). Per-slide closeness to a LibreOffice baseline is measured and gated in CI by the fidelity harness in the monorepo (`site/fidelity`) — mean fg-SSIM ≈ 0.78 across the corpus, with the residual gaps documented there. diff --git a/packages/preview/src/emf.ts b/packages/preview/src/emf.ts new file mode 100644 index 00000000..1de61572 --- /dev/null +++ b/packages/preview/src/emf.ts @@ -0,0 +1,276 @@ +const WORD_BYTES = 4; +const SHORT_BYTES = 2; +const RECORD_HEADER_BYTES = 8; +const HEADER_MIN_BYTES = 88; +const VALUE_RECORD_BYTES = 12; +const PAIR_RECORD_BYTES = 16; +const BOUNDS_RECORD_BYTES = 24; +const BRUSH_RECORD_BYTES = 24; +const EOF_MIN_BYTES = 20; +const HEADER_BOUNDS_OFFSET = 8; +const HEADER_FILE_SIZE_OFFSET = 48; +const HEADER_RECORD_COUNT_OFFSET = 52; +const VALUE_OFFSET = RECORD_HEADER_BYTES; +const SECOND_VALUE_OFFSET = VALUE_OFFSET + WORD_BYTES; +const BRUSH_COLOR_OFFSET = 16; +const RGB_CHANNEL_COUNT = 3; +const HEX_RADIX = 16; +const EMFPLUS_COMMENT = 0x2b464d45; +const SIGNATURE_OFFSET = 40; +const SIGNATURE = 0x464d4520; +const POINT_COUNT_OFFSET = 24; +const POINTS_OFFSET = 28; +const BEZIER_POINT_COUNT = 3; +const STOCK_OBJECT_FLAG = 0x80000000; +const REGION_COPY = 5; +const MAP_TEXT = 1; +const MAP_ANISOTROPIC = 8; +const BRUSH_SOLID = 0; +const BRUSH_NULL = 1; +const FILL_ALTERNATE = 1; +const FILL_WINDING = 2; +const STOCK_BRUSHES = ['#ffffff', '#c0c0c0', '#808080', '#404040', '#000000', 'none']; + +const RECORD = { + HEADER: 1, + POLYBEZIERTO: 5, + POLYLINETO: 6, + SETWINDOWEXTEX: 9, + SETWINDOWORGEX: 10, + SETVIEWPORTEXTEX: 11, + SETVIEWPORTORGEX: 12, + SETBRUSHORGEX: 13, + EOF: 14, + SETMAPMODE: 17, + SETBKMODE: 18, + SETPOLYFILLMODE: 19, + SETSTRETCHBLTMODE: 21, + SETTEXTALIGN: 22, + MOVETOEX: 27, + SELECTOBJECT: 37, + CREATEBRUSHINDIRECT: 39, + DELETEOBJECT: 40, + BEGINPATH: 59, + ENDPATH: 60, + CLOSEFIGURE: 61, + FILLPATH: 62, + SELECTCLIPPATH: 67, + COMMENT: 70, + EXTSELECTCLIPRGN: 75, + POLYBEZIERTO16: 88, + POLYLINETO16: 89, +} as const; + +// Unsupported drawing commands reject the whole image to avoid silently missing artwork. +export const renderEmfToSvg = (bytes: Uint8Array): string | null => { + if (bytes.byteLength < HEADER_MIN_BYTES) return null; + const file = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + file.getUint32(0, true) !== RECORD.HEADER || + file.getUint32(SIGNATURE_OFFSET, true) !== SIGNATURE + ) return null; + const left = file.getInt32(HEADER_BOUNDS_OFFSET, true); + const top = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES, true); + const width = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES * 2, true) - left; + const height = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES * 3, true) - top; + if ( + width <= 0 || height <= 0 || + file.getUint32(HEADER_FILE_SIZE_OFFSET, true) !== bytes.byteLength + ) return null; + + let windowX = 0; + let windowY = 0; + let windowWidth = 1; + let windowHeight = 1; + let viewportX = 0; + let viewportY = 0; + let viewportWidth = 1; + let viewportHeight = 1; + let mapMode: number = MAP_TEXT; + let fillRule = 'evenodd'; + let brush = '#ffffff'; + const brushes = new Map(); + let path: string[] = []; + let recordingPath = false; + let clip = ''; + let clipCount = 0; + const definitions: string[] = []; + const drawings: string[] = []; + let recordCount = 0; + const point = (x: number, y: number): string => { + if (mapMode === MAP_TEXT) return `${x} ${y}`; + const mappedX = ((x - windowX) * viewportWidth) / windowWidth + viewportX; + const mappedY = ((y - windowY) * viewportHeight) / windowHeight + viewportY; + return `${mappedX} ${mappedY}`; + }; + + for (let offset = 0; offset < bytes.byteLength; ) { + if (bytes.byteLength - offset < RECORD_HEADER_BYTES) return null; + const type = file.getUint32(offset, true); + const size = file.getUint32(offset + WORD_BYTES, true); + if ( + size < RECORD_HEADER_BYTES || size % WORD_BYTES !== 0 || + size > bytes.byteLength - offset + ) return null; + const record = new DataView(bytes.buffer, bytes.byteOffset + offset, size); + const unsigned = (at: number) => record.getUint32(at, true); + const signed = (at: number) => record.getInt32(at, true); + recordCount++; + switch (type) { + case RECORD.HEADER: + if (offset !== 0 || size < HEADER_MIN_BYTES) return null; + break; + case RECORD.EOF: + if ( + size < EOF_MIN_BYTES || offset + size !== bytes.byteLength || recordingPath || + recordCount !== file.getUint32(HEADER_RECORD_COUNT_OFFSET, true) + ) return null; + return `${definitions.join('')}${drawings.join('')}`; + case RECORD.SETMAPMODE: + if (size < VALUE_RECORD_BYTES) return null; + mapMode = unsigned(VALUE_OFFSET); + if (mapMode !== MAP_TEXT && mapMode !== MAP_ANISOTROPIC) return null; + break; + case RECORD.SETWINDOWORGEX: + case RECORD.SETVIEWPORTORGEX: + case RECORD.SETWINDOWEXTEX: + case RECORD.SETVIEWPORTEXTEX: { + if (size < PAIR_RECORD_BYTES) return null; + const x = signed(VALUE_OFFSET); + const y = signed(SECOND_VALUE_OFFSET); + if (type === RECORD.SETWINDOWORGEX) { + windowX = x; + windowY = y; + } else if (type === RECORD.SETVIEWPORTORGEX) { + viewportX = x; + viewportY = y; + } else { + if (x === 0 || y === 0) return null; + if (type === RECORD.SETWINDOWEXTEX) { + windowWidth = x; + windowHeight = y; + } else { + viewportWidth = x; + viewportHeight = y; + } + } + break; + } + case RECORD.CREATEBRUSHINDIRECT: { + if (size < BRUSH_RECORD_BYTES) return null; + const style = unsigned(SECOND_VALUE_OFFSET); + if (style !== BRUSH_SOLID && style !== BRUSH_NULL) return null; + const color = [ + ...bytes.subarray(offset + BRUSH_COLOR_OFFSET, offset + BRUSH_COLOR_OFFSET + RGB_CHANNEL_COUNT), + ].map((channel) => channel.toString(HEX_RADIX).padStart(2, '0')).join(''); + brushes.set(unsigned(VALUE_OFFSET), style === BRUSH_NULL ? 'none' : `#${color}`); + break; + } + case RECORD.SELECTOBJECT: { + if (size < VALUE_RECORD_BYTES) return null; + const handle = unsigned(VALUE_OFFSET); + const selected = handle >= STOCK_OBJECT_FLAG + ? STOCK_BRUSHES[handle - STOCK_OBJECT_FLAG] + : brushes.get(handle); + if (selected === undefined) return null; + brush = selected; + break; + } + case RECORD.DELETEOBJECT: + if (size < VALUE_RECORD_BYTES) return null; + brushes.delete(unsigned(VALUE_OFFSET)); + break; + case RECORD.SETPOLYFILLMODE: + if ( + size < VALUE_RECORD_BYTES || + (unsigned(VALUE_OFFSET) !== FILL_ALTERNATE && unsigned(VALUE_OFFSET) !== FILL_WINDING) + ) return null; + fillRule = unsigned(VALUE_OFFSET) === FILL_WINDING ? 'nonzero' : 'evenodd'; + break; + case RECORD.BEGINPATH: + path = []; + recordingPath = true; + break; + case RECORD.MOVETOEX: + if (size < PAIR_RECORD_BYTES || !recordingPath) return null; + path.push(`M${point(signed(VALUE_OFFSET), signed(SECOND_VALUE_OFFSET))}`); + break; + case RECORD.POLYLINETO: + case RECORD.POLYLINETO16: + case RECORD.POLYBEZIERTO: + case RECORD.POLYBEZIERTO16: { + if (size < POINTS_OFFSET || !recordingPath || path.length === 0) return null; + const short = type === RECORD.POLYLINETO16 || type === RECORD.POLYBEZIERTO16; + const bezier = type === RECORD.POLYBEZIERTO || type === RECORD.POLYBEZIERTO16; + const coordinateBytes = short ? SHORT_BYTES : WORD_BYTES; + const pointBytes = coordinateBytes * 2; + const count = unsigned(POINT_COUNT_OFFSET); + if ( + count > Math.floor((size - POINTS_OFFSET) / pointBytes) || + (bezier && count % BEZIER_POINT_COUNT !== 0) + ) return null; + for (let index = 0; index < count; index++) { + const at = POINTS_OFFSET + index * pointBytes; + const x = short ? record.getInt16(at, true) : signed(at); + const y = short ? record.getInt16(at + coordinateBytes, true) : signed(at + coordinateBytes); + const command = bezier ? (index % BEZIER_POINT_COUNT === 0 ? 'C' : ' ') : 'L'; + path.push(`${command}${point(x, y)}`); + } + break; + } + case RECORD.CLOSEFIGURE: + if (!recordingPath || path.length === 0) return null; + path.push('Z'); + break; + case RECORD.ENDPATH: + if (!recordingPath) return null; + recordingPath = false; + break; + case RECORD.FILLPATH: + if (size < BOUNDS_RECORD_BYTES || recordingPath || path.length === 0) return null; + drawings.push(``); + path = []; + break; + case RECORD.SELECTCLIPPATH: { + if ( + size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) !== REGION_COPY || + recordingPath || path.length === 0 + ) return null; + const id = `emf-clip-${clipCount++}`; + definitions.push( + ``, + ); + clip = ` clip-path="url(#${id})"`; + path = []; + break; + } + case RECORD.EXTSELECTCLIPRGN: + if ( + size < PAIR_RECORD_BYTES || unsigned(VALUE_OFFSET) !== 0 || + unsigned(SECOND_VALUE_OFFSET) !== REGION_COPY + ) return null; + clip = ''; + break; + case RECORD.COMMENT: + if (size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) > size - VALUE_RECORD_BYTES) return null; + // EMF+ comments can contain drawing commands absent from the GDI records. + if ( + unsigned(VALUE_OFFSET) >= WORD_BYTES && unsigned(SECOND_VALUE_OFFSET) === EMFPLUS_COMMENT + ) return null; + break; + // Text/bitmap settings and the brush origin do not affect solid-filled paths. + case RECORD.SETBKMODE: + case RECORD.SETSTRETCHBLTMODE: + case RECORD.SETTEXTALIGN: + if (size < VALUE_RECORD_BYTES) return null; + break; + case RECORD.SETBRUSHORGEX: + if (size < PAIR_RECORD_BYTES) return null; + break; + default: + return null; + } + offset += size; + } + return null; +}; diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 5c1d923b..1926c4b6 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -140,6 +140,7 @@ import { type TableCellParagraph, type TextFormat, } from '@office-kit/pptx'; +import { renderEmfToSvg } from './emf.ts'; import { defaultMeasurer, layoutTextSvg, @@ -279,6 +280,13 @@ const renderPicture = ( if (bytes && format) { mime = imageMime[format] ?? null; } + if (bytes && !mime) { + const svg = renderEmfToSvg(bytes); + if (svg !== null) { + bytes = new TextEncoder().encode(svg); + mime = 'image/svg+xml'; + } + } if (bytes && !mime) { // Format detection failed (likely EMF/WMF/HEIC). Try the part // name's extension — most browsers can still render HEIC / AVIF @@ -544,7 +552,8 @@ const gradientDef = ( theme: PresentationTheme | null, ): { defs: string; fillAttr: string } => { const id = mintId(); - const stops = grad.stops + const orderedStops = [...grad.stops].sort((a, b) => a.offset - b.offset); + const stops = orderedStops .map( (s) => ``, @@ -560,7 +569,7 @@ const gradientDef = ( // ECMA-376 stops paint outward from the focus center; SVG's radial // gradient paints from cx/cy out to r. Reverse the stops so the // first-stop color sits at the center, matching PowerPoint. - const reversed = grad.stops + const reversed = orderedStops .slice() .reverse() .map( diff --git a/test/preview-imported-artwork.test.ts b/test/preview-imported-artwork.test.ts new file mode 100644 index 00000000..c52a9543 --- /dev/null +++ b/test/preview-imported-artwork.test.ts @@ -0,0 +1,183 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { + addSlide, addSlideImage, addSlideShape, findSlideLayout, getSlides, inches, + loadPresentation, savePresentation, setShapeGradientFill, +} from '../src/api/index.ts'; +import { readZip, writeZip } from '../src/internal/opc/index.ts'; +import { renderSlideToSvg } from '../packages/preview/src/index.ts'; +import { renderSlideToRgba } from '../packages/preview/src/node.ts'; +import { renderEmfToSvg } from '../packages/preview/src/emf.ts'; +import { buildPng } from './lib/build-png.ts'; + +const WORD_BYTES = 4; +const RECORD_HEADER_BYTES = 8; +const EMF_HEADER_BYTES = 88; +const EMF_SIGNATURE_OFFSET = 40; +const EMF_SIGNATURE = 0x464d4520; +const EMF_EXTENT = 100; +const record = (type: number, values: number[] = []): Uint8Array => { + const bytes = new Uint8Array(RECORD_HEADER_BYTES + values.length * WORD_BYTES); + const view = new DataView(bytes.buffer); + [type, bytes.length, ...values].forEach((value, index) => view.setUint32(index * WORD_BYTES, value, true)); + return bytes; +}; +const metafile = (records: Uint8Array[]): Uint8Array => { + const header = new Uint8Array(EMF_HEADER_BYTES); + const view = new DataView(header.buffer); + view.setUint32(0, 1, true); + view.setUint32(WORD_BYTES, EMF_HEADER_BYTES, true); + view.setInt32(16, EMF_EXTENT, true); + view.setInt32(20, EMF_EXTENT, true); + view.setUint32(EMF_SIGNATURE_OFFSET, EMF_SIGNATURE, true); + const parts = [header, ...records, record(14, [0, 16, 20])]; + const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.length; } + new DataView(bytes.buffer).setUint32(48, bytes.length, true); + new DataView(bytes.buffer).setUint32(52, parts.length, true); + return bytes; +}; +const pathRecords = [ + record(17, [8]), record(9, [EMF_EXTENT, EMF_EXTENT]), record(11, [EMF_EXTENT, EMF_EXTENT]), + record(39, [1, 0, 0x00ffffff, 0]), record(37, [1]), record(19, [2]), + record(59), record(27, [10, 10]), + record(6, [0, 0, 90, 90, 2, 90, 10, 90, 90]), + record(5, [0, 0, 90, 90, 3, 80, 90, 20, 90, 10, 10]), + record(61), record(60), record(62, [0, 0, 90, 90]), +]; + +const blankSlide = async () => { + const pres = await loadPresentation(await readFile(new URL('./fixtures/minimal/blank.pptx', import.meta.url))); + const layout = findSlideLayout(pres, 'Blank')!; + return { pres, slide: addSlide(pres, { layout }) }; +}; +const loadMetafile = async (bytes: Uint8Array) => { + const { pres, slide } = await blankSlide(); + addSlideImage(slide, buildPng(1, 1, [0, 0, 0]), { x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); + const { entries } = readZip(await savePresentation(pres)); + const modified = entries.map((entry) => { + if (entry.name.startsWith('ppt/media/')) return { ...entry, name: entry.name.replace('.png', '.emf'), data: bytes }; + if (entry.name.endsWith('.rels') || entry.name === '[Content_Types].xml') { + return { ...entry, data: new TextEncoder().encode(new TextDecoder().decode(entry.data).replaceAll('.png', '.emf').replace('Extension="png" ContentType="image/png"', 'Extension="emf" ContentType="image/x-emf"')) }; + } + return entry; + }); + const loaded = await loadPresentation(writeZip(modified)); + return { pres: loaded, slide: getSlides(loaded).at(-1)! }; +}; +const renderMetafile = async (bytes: Uint8Array) => { + const { pres, slide } = await loadMetafile(bytes); + return renderSlideToSvg(pres, slide); +}; + +describe('imported artwork', () => { + it('位置が順不同のグラデーションを位置順に描画する', async () => { + const { pres, slide } = await blankSlide(); + const shape = addSlideShape(slide, { preset: 'rect', x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); + setShapeGradientFill(shape, { angleDeg: 90, stops: [ + { offset: 0.52, color: '#003C7A' }, { offset: 0, color: '#002060' }, { offset: 1, color: '#01A8DC' }, + ] }); + const svg = renderSlideToSvg(pres, slide); + const offsets = [...svg.matchAll(/ Number(match[1])); + expect(offsets).toEqual([0, 0.52, 1]); + }); + + it('EMF の塗りつぶしパスを透過 SVG 画像として描画する', async () => { + const svg = await renderMetafile(metafile(pathRecords)); + expect(svg).not.toContain('data-pptx-fallback="image"'); + const encoded = svg.match(/href="data:image\/svg\+xml;base64,([^"]+)"/)?.[1]; + expect(encoded).toBeDefined(); + const artwork = Buffer.from(encoded!, 'base64').toString(); + expect(artwork).toContain('fill="#ffffff"'); + expect(artwork).toContain('L90 10'); + expect(artwork).toContain('C80 90 20 90 10 10'); + }); + + it('Node の PNG 出力にも EMF が描画され、パスの外側は透明になる', async () => { + const redPaths = pathRecords.map((bytes) => new DataView(bytes.buffer).getUint32(0, true) === 39 + ? record(39, [1, 0, 0x000000ff, 0]) : bytes); + const { pres, slide } = await loadMetafile(metafile(redPaths)); + const { image } = renderSlideToRgba(pres, slide, { width: 960 }); + const channels = 4; + const pixel = (x: number, y: number) => [...image.data.subarray((y * image.width + x) * channels, (y * image.width + x + 1) * channels)]; + expect(pixel(40, 40)).toEqual([255, 0, 0, 255]); + expect(pixel(2, 2)).toEqual([255, 255, 255, 255]); + }); + + it('放射状グラデーションも位置順に並べてから反転する', async () => { + const { pres, slide } = await blankSlide(); + const shape = addSlideShape(slide, { preset: 'rect', x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); + setShapeGradientFill(shape, { path: 'circle', stops: [ + { offset: 0.52, color: '#003C7A' }, { offset: 0, color: '#002060' }, { offset: 1, color: '#01A8DC' }, + ] }); + const svg = renderSlideToSvg(pres, slide); + expect([...svg.matchAll(/ Number(match[1]))).toEqual([0, 0.48, 1]); + }); + + it('未対応の描画命令では部分描画せず代替表示を維持する', async () => { + expect(await renderMetafile(metafile([...pathRecords, record(84)]))).toContain('data-pptx-fallback="image"'); + }); + + it('壊れたレコード長でも例外や無限ループにせず代替表示する', async () => { + const bytes = metafile(pathRecords); + new DataView(bytes.buffer).setUint32(EMF_HEADER_BYTES + WORD_BYTES, 0, true); + expect(await renderMetafile(bytes)).toContain('data-pptx-fallback="image"'); + }); +}); + +describe('EMF path decoding', () => { + it('16 ビット座標の折れ線とベジェ曲線を符号付きで読み取る', () => { + const packedPoint = (x: number, y: number) => ((y & 0xffff) << 16) | (x & 0xffff); + const bytes = metafile([ + record(59), record(27, [0, 0]), + record(89, [0, 0, 90, 90, 1, packedPoint(-10, 20)]), + record(88, [0, 0, 90, 90, 3, packedPoint(-20, 30), packedPoint(40, -50), packedPoint(60, 70)]), + record(61), record(60), record(62, [0, 0, 90, 90]), + ]); + expect(renderEmfToSvg(bytes)).toContain('M0 0L-10 20C-20 30 40 -50 60 70Z'); + }); + + it('ウィンドウとビューポートの原点・倍率を適用してクリップする', () => { + const bytes = metafile([ + record(17, [8]), record(10, [10, 20]), record(12, [5, 6]), + record(9, [100, 200]), record(11, [200, -400]), + record(59), record(27, [10, 20]), record(6, [0, 0, 100, 100, 2, 30, 20, 30, 40]), + record(61), record(60), record(67, [5]), + ...pathRecords, + record(75, [0, 5]), ...pathRecords, + ]); + const svg = renderEmfToSvg(bytes)!; + expect(svg).toContain('d="M5 6L45 6L45 -34Z" clip-rule="evenodd"'); + expect(svg.match(/clip-path="url\(#emf-clip-0\)"/g)).toHaveLength(1); + expect(svg.match(/fill="#ffffff"/g)).toHaveLength(2); + }); + + it.each([ + record(6, [0, 0, 90, 90, 0xffffffff]), + record(5, [0, 0, 90, 90, 1, 10, 10]), + record(27), + record(70, [100]), + record(9, [0, 100]), + ])('不正なレコードのデータ長や座標を拒否する', (invalid) => { + expect(renderEmfToSvg(metafile([ + record(59), record(27, [0, 0]), invalid, record(61), record(60), record(62, [0, 0, 90, 90]), + ]))).toBeNull(); + }); + + it('Uint8Array のオフセットを尊重する', () => { + const bytes = metafile(pathRecords); + const padded = new Uint8Array(bytes.length + WORD_BYTES); + padded.set(bytes, WORD_BYTES); + expect(renderEmfToSvg(padded.subarray(WORD_BYTES))).toEqual(renderEmfToSvg(bytes)); + }); + + it('末尾の欠落を拒否する', () => { + const bytes = metafile(pathRecords); + expect(renderEmfToSvg(bytes.subarray(0, bytes.length - WORD_BYTES))).toBeNull(); + }); + + it('EMF+ コメント内の描画を無視して部分描画しない', () => { + expect(renderEmfToSvg(metafile([...pathRecords, record(70, [WORD_BYTES, 0x2b464d45])]))).toBeNull(); + }); +}); From eac65d92a22cfc7e21cb31f18fd3d9d50767671f Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 00:07:54 +0900 Subject: [PATCH 2/5] style(preview): align artwork decoder and test formatting --- packages/preview/src/emf.ts | 67 +++++++---- test/preview-imported-artwork.test.ts | 166 ++++++++++++++++++++------ 2 files changed, 174 insertions(+), 59 deletions(-) diff --git a/packages/preview/src/emf.ts b/packages/preview/src/emf.ts index 1de61572..0b1f3e8f 100644 --- a/packages/preview/src/emf.ts +++ b/packages/preview/src/emf.ts @@ -68,15 +68,18 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { if ( file.getUint32(0, true) !== RECORD.HEADER || file.getUint32(SIGNATURE_OFFSET, true) !== SIGNATURE - ) return null; + ) + return null; const left = file.getInt32(HEADER_BOUNDS_OFFSET, true); const top = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES, true); const width = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES * 2, true) - left; const height = file.getInt32(HEADER_BOUNDS_OFFSET + WORD_BYTES * 3, true) - top; if ( - width <= 0 || height <= 0 || + width <= 0 || + height <= 0 || file.getUint32(HEADER_FILE_SIZE_OFFSET, true) !== bytes.byteLength - ) return null; + ) + return null; let windowX = 0; let windowY = 0; @@ -109,9 +112,11 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { const type = file.getUint32(offset, true); const size = file.getUint32(offset + WORD_BYTES, true); if ( - size < RECORD_HEADER_BYTES || size % WORD_BYTES !== 0 || + size < RECORD_HEADER_BYTES || + size % WORD_BYTES !== 0 || size > bytes.byteLength - offset - ) return null; + ) + return null; const record = new DataView(bytes.buffer, bytes.byteOffset + offset, size); const unsigned = (at: number) => record.getUint32(at, true); const signed = (at: number) => record.getInt32(at, true); @@ -122,9 +127,12 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { break; case RECORD.EOF: if ( - size < EOF_MIN_BYTES || offset + size !== bytes.byteLength || recordingPath || + size < EOF_MIN_BYTES || + offset + size !== bytes.byteLength || + recordingPath || recordCount !== file.getUint32(HEADER_RECORD_COUNT_OFFSET, true) - ) return null; + ) + return null; return `${definitions.join('')}${drawings.join('')}`; case RECORD.SETMAPMODE: if (size < VALUE_RECORD_BYTES) return null; @@ -161,17 +169,23 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { const style = unsigned(SECOND_VALUE_OFFSET); if (style !== BRUSH_SOLID && style !== BRUSH_NULL) return null; const color = [ - ...bytes.subarray(offset + BRUSH_COLOR_OFFSET, offset + BRUSH_COLOR_OFFSET + RGB_CHANNEL_COUNT), - ].map((channel) => channel.toString(HEX_RADIX).padStart(2, '0')).join(''); + ...bytes.subarray( + offset + BRUSH_COLOR_OFFSET, + offset + BRUSH_COLOR_OFFSET + RGB_CHANNEL_COUNT, + ), + ] + .map((channel) => channel.toString(HEX_RADIX).padStart(2, '0')) + .join(''); brushes.set(unsigned(VALUE_OFFSET), style === BRUSH_NULL ? 'none' : `#${color}`); break; } case RECORD.SELECTOBJECT: { if (size < VALUE_RECORD_BYTES) return null; const handle = unsigned(VALUE_OFFSET); - const selected = handle >= STOCK_OBJECT_FLAG - ? STOCK_BRUSHES[handle - STOCK_OBJECT_FLAG] - : brushes.get(handle); + const selected = + handle >= STOCK_OBJECT_FLAG + ? STOCK_BRUSHES[handle - STOCK_OBJECT_FLAG] + : brushes.get(handle); if (selected === undefined) return null; brush = selected; break; @@ -184,7 +198,8 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { if ( size < VALUE_RECORD_BYTES || (unsigned(VALUE_OFFSET) !== FILL_ALTERNATE && unsigned(VALUE_OFFSET) !== FILL_WINDING) - ) return null; + ) + return null; fillRule = unsigned(VALUE_OFFSET) === FILL_WINDING ? 'nonzero' : 'evenodd'; break; case RECORD.BEGINPATH: @@ -208,7 +223,8 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { if ( count > Math.floor((size - POINTS_OFFSET) / pointBytes) || (bezier && count % BEZIER_POINT_COUNT !== 0) - ) return null; + ) + return null; for (let index = 0; index < count; index++) { const at = POINTS_OFFSET + index * pointBytes; const x = short ? record.getInt16(at, true) : signed(at); @@ -233,9 +249,12 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { break; case RECORD.SELECTCLIPPATH: { if ( - size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) !== REGION_COPY || - recordingPath || path.length === 0 - ) return null; + size < VALUE_RECORD_BYTES || + unsigned(VALUE_OFFSET) !== REGION_COPY || + recordingPath || + path.length === 0 + ) + return null; const id = `emf-clip-${clipCount++}`; definitions.push( ``, @@ -246,17 +265,19 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { } case RECORD.EXTSELECTCLIPRGN: if ( - size < PAIR_RECORD_BYTES || unsigned(VALUE_OFFSET) !== 0 || + size < PAIR_RECORD_BYTES || + unsigned(VALUE_OFFSET) !== 0 || unsigned(SECOND_VALUE_OFFSET) !== REGION_COPY - ) return null; + ) + return null; clip = ''; break; case RECORD.COMMENT: - if (size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) > size - VALUE_RECORD_BYTES) return null; + if (size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) > size - VALUE_RECORD_BYTES) + return null; // EMF+ comments can contain drawing commands absent from the GDI records. - if ( - unsigned(VALUE_OFFSET) >= WORD_BYTES && unsigned(SECOND_VALUE_OFFSET) === EMFPLUS_COMMENT - ) return null; + if (unsigned(VALUE_OFFSET) >= WORD_BYTES && unsigned(SECOND_VALUE_OFFSET) === EMFPLUS_COMMENT) + return null; break; // Text/bitmap settings and the brush origin do not affect solid-filled paths. case RECORD.SETBKMODE: diff --git a/test/preview-imported-artwork.test.ts b/test/preview-imported-artwork.test.ts index c52a9543..cd74656e 100644 --- a/test/preview-imported-artwork.test.ts +++ b/test/preview-imported-artwork.test.ts @@ -1,8 +1,15 @@ import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; import { - addSlide, addSlideImage, addSlideShape, findSlideLayout, getSlides, inches, - loadPresentation, savePresentation, setShapeGradientFill, + addSlide, + addSlideImage, + addSlideShape, + findSlideLayout, + getSlides, + inches, + loadPresentation, + savePresentation, + setShapeGradientFill, } from '../src/api/index.ts'; import { readZip, writeZip } from '../src/internal/opc/index.ts'; import { renderSlideToSvg } from '../packages/preview/src/index.ts'; @@ -19,7 +26,9 @@ const EMF_EXTENT = 100; const record = (type: number, values: number[] = []): Uint8Array => { const bytes = new Uint8Array(RECORD_HEADER_BYTES + values.length * WORD_BYTES); const view = new DataView(bytes.buffer); - [type, bytes.length, ...values].forEach((value, index) => view.setUint32(index * WORD_BYTES, value, true)); + [type, bytes.length, ...values].forEach((value, index) => + view.setUint32(index * WORD_BYTES, value, true), + ); return bytes; }; const metafile = (records: Uint8Array[]): Uint8Array => { @@ -33,33 +42,62 @@ const metafile = (records: Uint8Array[]): Uint8Array => { const parts = [header, ...records, record(14, [0, 16, 20])]; const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); let offset = 0; - for (const part of parts) { bytes.set(part, offset); offset += part.length; } + for (const part of parts) { + bytes.set(part, offset); + offset += part.length; + } new DataView(bytes.buffer).setUint32(48, bytes.length, true); new DataView(bytes.buffer).setUint32(52, parts.length, true); return bytes; }; const pathRecords = [ - record(17, [8]), record(9, [EMF_EXTENT, EMF_EXTENT]), record(11, [EMF_EXTENT, EMF_EXTENT]), - record(39, [1, 0, 0x00ffffff, 0]), record(37, [1]), record(19, [2]), - record(59), record(27, [10, 10]), + record(17, [8]), + record(9, [EMF_EXTENT, EMF_EXTENT]), + record(11, [EMF_EXTENT, EMF_EXTENT]), + record(39, [1, 0, 0x00ffffff, 0]), + record(37, [1]), + record(19, [2]), + record(59), + record(27, [10, 10]), record(6, [0, 0, 90, 90, 2, 90, 10, 90, 90]), record(5, [0, 0, 90, 90, 3, 80, 90, 20, 90, 10, 10]), - record(61), record(60), record(62, [0, 0, 90, 90]), + record(61), + record(60), + record(62, [0, 0, 90, 90]), ]; const blankSlide = async () => { - const pres = await loadPresentation(await readFile(new URL('./fixtures/minimal/blank.pptx', import.meta.url))); + const pres = await loadPresentation( + await readFile(new URL('./fixtures/minimal/blank.pptx', import.meta.url)), + ); const layout = findSlideLayout(pres, 'Blank')!; return { pres, slide: addSlide(pres, { layout }) }; }; const loadMetafile = async (bytes: Uint8Array) => { const { pres, slide } = await blankSlide(); - addSlideImage(slide, buildPng(1, 1, [0, 0, 0]), { x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); + addSlideImage(slide, buildPng(1, 1, [0, 0, 0]), { + x: inches(0), + y: inches(0), + w: inches(1), + h: inches(1), + }); const { entries } = readZip(await savePresentation(pres)); const modified = entries.map((entry) => { - if (entry.name.startsWith('ppt/media/')) return { ...entry, name: entry.name.replace('.png', '.emf'), data: bytes }; + if (entry.name.startsWith('ppt/media/')) + return { ...entry, name: entry.name.replace('.png', '.emf'), data: bytes }; if (entry.name.endsWith('.rels') || entry.name === '[Content_Types].xml') { - return { ...entry, data: new TextEncoder().encode(new TextDecoder().decode(entry.data).replaceAll('.png', '.emf').replace('Extension="png" ContentType="image/png"', 'Extension="emf" ContentType="image/x-emf"')) }; + return { + ...entry, + data: new TextEncoder().encode( + new TextDecoder() + .decode(entry.data) + .replaceAll('.png', '.emf') + .replace( + 'Extension="png" ContentType="image/png"', + 'Extension="emf" ContentType="image/x-emf"', + ), + ), + }; } return entry; }); @@ -74,10 +112,21 @@ const renderMetafile = async (bytes: Uint8Array) => { describe('imported artwork', () => { it('位置が順不同のグラデーションを位置順に描画する', async () => { const { pres, slide } = await blankSlide(); - const shape = addSlideShape(slide, { preset: 'rect', x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); - setShapeGradientFill(shape, { angleDeg: 90, stops: [ - { offset: 0.52, color: '#003C7A' }, { offset: 0, color: '#002060' }, { offset: 1, color: '#01A8DC' }, - ] }); + const shape = addSlideShape(slide, { + preset: 'rect', + x: inches(0), + y: inches(0), + w: inches(1), + h: inches(1), + }); + setShapeGradientFill(shape, { + angleDeg: 90, + stops: [ + { offset: 0.52, color: '#003C7A' }, + { offset: 0, color: '#002060' }, + { offset: 1, color: '#01A8DC' }, + ], + }); const svg = renderSlideToSvg(pres, slide); const offsets = [...svg.matchAll(/ Number(match[1])); expect(offsets).toEqual([0, 0.52, 1]); @@ -95,28 +144,51 @@ describe('imported artwork', () => { }); it('Node の PNG 出力にも EMF が描画され、パスの外側は透明になる', async () => { - const redPaths = pathRecords.map((bytes) => new DataView(bytes.buffer).getUint32(0, true) === 39 - ? record(39, [1, 0, 0x000000ff, 0]) : bytes); + const redPaths = pathRecords.map((bytes) => + new DataView(bytes.buffer).getUint32(0, true) === 39 + ? record(39, [1, 0, 0x000000ff, 0]) + : bytes, + ); const { pres, slide } = await loadMetafile(metafile(redPaths)); const { image } = renderSlideToRgba(pres, slide, { width: 960 }); const channels = 4; - const pixel = (x: number, y: number) => [...image.data.subarray((y * image.width + x) * channels, (y * image.width + x + 1) * channels)]; + const pixel = (x: number, y: number) => [ + ...image.data.subarray( + (y * image.width + x) * channels, + (y * image.width + x + 1) * channels, + ), + ]; expect(pixel(40, 40)).toEqual([255, 0, 0, 255]); expect(pixel(2, 2)).toEqual([255, 255, 255, 255]); }); it('放射状グラデーションも位置順に並べてから反転する', async () => { const { pres, slide } = await blankSlide(); - const shape = addSlideShape(slide, { preset: 'rect', x: inches(0), y: inches(0), w: inches(1), h: inches(1) }); - setShapeGradientFill(shape, { path: 'circle', stops: [ - { offset: 0.52, color: '#003C7A' }, { offset: 0, color: '#002060' }, { offset: 1, color: '#01A8DC' }, - ] }); + const shape = addSlideShape(slide, { + preset: 'rect', + x: inches(0), + y: inches(0), + w: inches(1), + h: inches(1), + }); + setShapeGradientFill(shape, { + path: 'circle', + stops: [ + { offset: 0.52, color: '#003C7A' }, + { offset: 0, color: '#002060' }, + { offset: 1, color: '#01A8DC' }, + ], + }); const svg = renderSlideToSvg(pres, slide); - expect([...svg.matchAll(/ Number(match[1]))).toEqual([0, 0.48, 1]); + expect([...svg.matchAll(/ Number(match[1]))).toEqual([ + 0, 0.48, 1, + ]); }); it('未対応の描画命令では部分描画せず代替表示を維持する', async () => { - expect(await renderMetafile(metafile([...pathRecords, record(84)]))).toContain('data-pptx-fallback="image"'); + expect(await renderMetafile(metafile([...pathRecords, record(84)]))).toContain( + 'data-pptx-fallback="image"', + ); }); it('壊れたレコード長でも例外や無限ループにせず代替表示する', async () => { @@ -130,22 +202,33 @@ describe('EMF path decoding', () => { it('16 ビット座標の折れ線とベジェ曲線を符号付きで読み取る', () => { const packedPoint = (x: number, y: number) => ((y & 0xffff) << 16) | (x & 0xffff); const bytes = metafile([ - record(59), record(27, [0, 0]), + record(59), + record(27, [0, 0]), record(89, [0, 0, 90, 90, 1, packedPoint(-10, 20)]), record(88, [0, 0, 90, 90, 3, packedPoint(-20, 30), packedPoint(40, -50), packedPoint(60, 70)]), - record(61), record(60), record(62, [0, 0, 90, 90]), + record(61), + record(60), + record(62, [0, 0, 90, 90]), ]); expect(renderEmfToSvg(bytes)).toContain('M0 0L-10 20C-20 30 40 -50 60 70Z'); }); it('ウィンドウとビューポートの原点・倍率を適用してクリップする', () => { const bytes = metafile([ - record(17, [8]), record(10, [10, 20]), record(12, [5, 6]), - record(9, [100, 200]), record(11, [200, -400]), - record(59), record(27, [10, 20]), record(6, [0, 0, 100, 100, 2, 30, 20, 30, 40]), - record(61), record(60), record(67, [5]), + record(17, [8]), + record(10, [10, 20]), + record(12, [5, 6]), + record(9, [100, 200]), + record(11, [200, -400]), + record(59), + record(27, [10, 20]), + record(6, [0, 0, 100, 100, 2, 30, 20, 30, 40]), + record(61), + record(60), + record(67, [5]), + ...pathRecords, + record(75, [0, 5]), ...pathRecords, - record(75, [0, 5]), ...pathRecords, ]); const svg = renderEmfToSvg(bytes)!; expect(svg).toContain('d="M5 6L45 6L45 -34Z" clip-rule="evenodd"'); @@ -160,9 +243,18 @@ describe('EMF path decoding', () => { record(70, [100]), record(9, [0, 100]), ])('不正なレコードのデータ長や座標を拒否する', (invalid) => { - expect(renderEmfToSvg(metafile([ - record(59), record(27, [0, 0]), invalid, record(61), record(60), record(62, [0, 0, 90, 90]), - ]))).toBeNull(); + expect( + renderEmfToSvg( + metafile([ + record(59), + record(27, [0, 0]), + invalid, + record(61), + record(60), + record(62, [0, 0, 90, 90]), + ]), + ), + ).toBeNull(); }); it('Uint8Array のオフセットを尊重する', () => { @@ -178,6 +270,8 @@ describe('EMF path decoding', () => { }); it('EMF+ コメント内の描画を無視して部分描画しない', () => { - expect(renderEmfToSvg(metafile([...pathRecords, record(70, [WORD_BYTES, 0x2b464d45])]))).toBeNull(); + expect( + renderEmfToSvg(metafile([...pathRecords, record(70, [WORD_BYTES, 0x2b464d45])])), + ).toBeNull(); }); }); From c6acc5ea19ec7698e81b018ab69655bc17ac1766 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 00:08:57 +0900 Subject: [PATCH 3/5] ci: diagnose formatting differences for artwork fix --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c1dee7..af0b01ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,12 @@ jobs: - name: Format check run: pnpm format:check + - name: Show formatting diff + if: failure() + run: | + pnpm exec oxfmt --write packages/preview/src/emf.ts test/preview-imported-artwork.test.ts + git diff -- packages/preview/src/emf.ts test/preview-imported-artwork.test.ts + - name: Lint run: pnpm lint From d4e4e7a02d1967b1958c51c2eb5f6bc273085529 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 00:10:07 +0900 Subject: [PATCH 4/5] style(preview): apply CI formatting and remove diagnostics --- .github/workflows/ci.yml | 6 ------ packages/preview/src/emf.ts | 19 +++++++++++-------- test/preview-imported-artwork.test.ts | 11 ++++++++++- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af0b01ab..42c1dee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,12 +44,6 @@ jobs: - name: Format check run: pnpm format:check - - name: Show formatting diff - if: failure() - run: | - pnpm exec oxfmt --write packages/preview/src/emf.ts test/preview-imported-artwork.test.ts - git diff -- packages/preview/src/emf.ts test/preview-imported-artwork.test.ts - - name: Lint run: pnpm lint diff --git a/packages/preview/src/emf.ts b/packages/preview/src/emf.ts index 0b1f3e8f..00cb20e0 100644 --- a/packages/preview/src/emf.ts +++ b/packages/preview/src/emf.ts @@ -111,11 +111,7 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { if (bytes.byteLength - offset < RECORD_HEADER_BYTES) return null; const type = file.getUint32(offset, true); const size = file.getUint32(offset + WORD_BYTES, true); - if ( - size < RECORD_HEADER_BYTES || - size % WORD_BYTES !== 0 || - size > bytes.byteLength - offset - ) + if (size < RECORD_HEADER_BYTES || size % WORD_BYTES !== 0 || size > bytes.byteLength - offset) return null; const record = new DataView(bytes.buffer, bytes.byteOffset + offset, size); const unsigned = (at: number) => record.getUint32(at, true); @@ -228,7 +224,9 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { for (let index = 0; index < count; index++) { const at = POINTS_OFFSET + index * pointBytes; const x = short ? record.getInt16(at, true) : signed(at); - const y = short ? record.getInt16(at + coordinateBytes, true) : signed(at + coordinateBytes); + const y = short + ? record.getInt16(at + coordinateBytes, true) + : signed(at + coordinateBytes); const command = bezier ? (index % BEZIER_POINT_COUNT === 0 ? 'C' : ' ') : 'L'; path.push(`${command}${point(x, y)}`); } @@ -244,7 +242,9 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { break; case RECORD.FILLPATH: if (size < BOUNDS_RECORD_BYTES || recordingPath || path.length === 0) return null; - drawings.push(``); + drawings.push( + ``, + ); path = []; break; case RECORD.SELECTCLIPPATH: { @@ -276,7 +276,10 @@ export const renderEmfToSvg = (bytes: Uint8Array): string | null => { if (size < VALUE_RECORD_BYTES || unsigned(VALUE_OFFSET) > size - VALUE_RECORD_BYTES) return null; // EMF+ comments can contain drawing commands absent from the GDI records. - if (unsigned(VALUE_OFFSET) >= WORD_BYTES && unsigned(SECOND_VALUE_OFFSET) === EMFPLUS_COMMENT) + if ( + unsigned(VALUE_OFFSET) >= WORD_BYTES && + unsigned(SECOND_VALUE_OFFSET) === EMFPLUS_COMMENT + ) return null; break; // Text/bitmap settings and the brush origin do not affect solid-filled paths. diff --git a/test/preview-imported-artwork.test.ts b/test/preview-imported-artwork.test.ts index cd74656e..ce2e6966 100644 --- a/test/preview-imported-artwork.test.ts +++ b/test/preview-imported-artwork.test.ts @@ -205,7 +205,16 @@ describe('EMF path decoding', () => { record(59), record(27, [0, 0]), record(89, [0, 0, 90, 90, 1, packedPoint(-10, 20)]), - record(88, [0, 0, 90, 90, 3, packedPoint(-20, 30), packedPoint(40, -50), packedPoint(60, 70)]), + record(88, [ + 0, + 0, + 90, + 90, + 3, + packedPoint(-20, 30), + packedPoint(40, -50), + packedPoint(60, 70), + ]), record(61), record(60), record(62, [0, 0, 90, 90]), From 32afc9d917eadb6d0399d518b1b4d434517fab28 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 00:10:49 +0900 Subject: [PATCH 5/5] docs(preview): clarify unsupported image fallback --- packages/preview/src/render-slide.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 1926c4b6..c7c02460 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -260,10 +260,8 @@ const mimeFromPartName = (name: string | null): string | null => { return EXT_TO_MIME[ext] ?? null; }; -// Render the picture if @office-kit/pptx handed us bytes; fall back to a -// labelled placeholder describing why nothing is drawn. EMF / WMF -// pictures still won't display (no browser can decode them) but the -// label tells the user what's there. +// Unsupported image formats retain a labelled placeholder so users can +// identify artwork that the preview cannot render. const renderPicture = ( shape: SlideShapeData, pres: PresentationData,