Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/drawing-layout-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Drawing layout integration
on:
pull_request:
push:
branches: [main, feature/drawing-layout-contract]
permissions:
contents: read
concurrency:
group: drawing-layout-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- name: Install build dependencies
run: npm install --ignore-scripts --package-lock=false
- name: Check drawing source formatting with actionable diff
run: |
if ! node_modules/.bin/oxfmt --check src/xlsx/drawing-layout.ts test/drawing-layout.test.ts; then
node_modules/.bin/oxfmt src/xlsx/drawing-layout.ts test/drawing-layout.test.ts
git diff -- src/xlsx/drawing-layout.ts test/drawing-layout.test.ts
exit 1
fi
- name: Check public parser and writer contract
run: npm run test:drawing-layout
- name: Type check
run: npm run typecheck
- name: Full library regression suite
run: node_modules/.bin/vitest run
- name: Preserve successful source and build for integration verification
run: |
git rev-parse HEAD > integration-revision.txt
tar -czf drawing-layout-runtime.tar.gz src dist scripts docs test package.json build.config.ts tsconfig.json tsconfig.cli.json integration-revision.txt
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: drawing-layout-runtime
path: drawing-layout-runtime.tar.gz
retention-days: 3
coverage-baseline:
name: Measure unchanged base coverage (informational)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out the PR base, not the proposed changes
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- run: pnpm install --frozen-lockfile
- name: Record baseline without changing coverage gates
shell: bash
run: |
git rev-parse HEAD
set +e
pnpm coverage > "$RUNNER_TEMP/base-coverage.log" 2>&1
status=$?
set -e
tail -180 "$RUNNER_TEMP/base-coverage.log"
echo "Unchanged base coverage exit status: $status"
{
echo '## Unchanged base coverage (informational only)'
echo "Revision: $(git rev-parse HEAD)"
echo "Coverage exit status: $status"
echo 'The main CI coverage job still independently enforces all original thresholds.'
} >> "$GITHUB_STEP_SUMMARY"
29 changes: 29 additions & 0 deletions docs/drawing-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Drawing and Normal-font metadata

The maintained parser is the single owner of XLSX package relationships and
DrawingML coordinates. Renderers must not reopen the ZIP to repair missing fields.

`SheetImage.anchor` now preserves the container `kind` (`twoCell`, `oneCell`,
`absolute`), an explicitly saved `editAs`, exact EMU `extent` and (for absolute
placement) EMU `position`. Existing `from`/`to` markers and offsets remain intact.
Absent metadata remains optional for older caller-created models. `width` and
`height` are compatibility dimensions at 96 DPI, without premature integer rounding.
One inch is 914400 EMU; DPI, display zoom, hidden-axis layout, and browser coordinate
conversion belong to the renderer, not the parser.

A fixed-size placement must not be stretched just because it has a second saved
cell marker. A normal two-cell placement still derives its current displayed size
from the cell markers. The writer preserves container kind, edit behavior, marker
offsets and exact extents; cloning and worker structured cloning retain them too.
This change does not claim full support for grouped-shape transforms or image effects.

`Workbook.defaultFont` follows the built-in Normal style's reference chain:
`cellStyles[builtinId=0].xfId -> cellStyleXfs[xfId].fontId -> fonts[fontId]`.
It is not inferred from a cell or from a screenshot. Invalid references fall back
to the first font, as before. View, page setup, print area and page-break metadata
continue to come from the existing parser.

Run `npm run test:drawing-layout` for the post-build parser/writer/clone regression
checks. Fixtures are generated in memory and contain no customer data. A `prepare`
lifecycle script builds the package when installed through a pinned Git dependency;
consumers do not need a separately installed pnpm executable to run that build.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
"test": "pnpm lint && pnpm typecheck && vitest run",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.cli.json",
"release": "pnpm test && pnpm build && bumpp --commit --tag --push --all",
"prepack": "pnpm build"
"prepack": "npm run build",
"prepare": "npm run build",
"test:drawing-layout": "npm run build && node --test scripts/verify-drawing-layout.mjs"
},
"devDependencies": {
"@types/node": "^26.1.2",
Expand Down
173 changes: 173 additions & 0 deletions scripts/verify-drawing-layout.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/** Post-build public parser/writer contract checks; no customer documents. */
import assert from "node:assert/strict"
import test from "node:test"
import { readXlsx, writeXlsx, cloneSheet } from "../dist/index.mjs"
import { ZipReader } from "../dist/zip/reader.mjs"
import { ZipWriter } from "../dist/zip/writer.mjs"

const encode = new TextEncoder()
const decode = new TextDecoder()
const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])
const NS = "http://schemas.openxmlformats.org"
const marker = (name, row, col, rowOff = 0, colOff = 0) =>
`<xdr:${name}><xdr:col>${col}</xdr:col><xdr:colOff>${colOff}</xdr:colOff><xdr:row>${row}</xdr:row><xdr:rowOff>${rowOff}</xdr:rowOff></xdr:${name}>`
const extent = { cx: 1252822, cy: 962025 }
const pic = `<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="Synthetic"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="10001" y="20003"/><a:ext cx="${extent.cx}" cy="${extent.cy}"/></a:xfrm></xdr:spPr></xdr:pic>`
const from = marker("from", 0, 1, 257175, 133350)
const to = marker("to", 3, 8, 100965, 123158)
const two = (edit = "") =>
`<xdr:twoCellAnchor${edit ? ` editAs="${edit}"` : ""}>${from}${to}${pic}<xdr:clientData/></xdr:twoCellAnchor>`
const one = `<xdr:oneCellAnchor>${from}<xdr:ext cx="${extent.cx}" cy="${extent.cy}"/>${pic}<xdr:clientData/></xdr:oneCellAnchor>`
const absolute = `<xdr:absoluteAnchor><xdr:pos x="-3175" y="19051"/><xdr:ext cx="${extent.cx}" cy="${extent.cy}"/>${pic}<xdr:clientData/></xdr:absoluteAnchor>`

async function fixture(anchor, styleXml) {
const original = await writeXlsx({
sheets: [
{
name: "Geometry",
rows: [["ordinary cell"]],
images: [{ data: png, type: "png", anchor: { from: { row: 0, col: 0 } } }],
},
],
})
const zip = new ZipReader(original)
const out = new ZipWriter()
for (const path of zip.entries()) {
let bytes = await zip.extract(path)
if (path === "xl/drawings/drawing1.xml")
bytes = encode.encode(
`<xdr:wsDr xmlns:xdr="${NS}/drawingml/2006/spreadsheetDrawing" xmlns:a="${NS}/drawingml/2006/main" xmlns:r="${NS}/officeDocument/2006/relationships">${anchor}</xdr:wsDr>`,
)
if (path === "xl/styles.xml" && styleXml) bytes = encode.encode(styleXml)
if (path === "xl/worksheets/sheet1.xml" && styleXml)
bytes = encode.encode(decode.decode(bytes).replace("<c ", '<c s="0" '))
out.add(path, bytes, { compress: false })
}
return out.build()
}
const imageOf = async (bytes) => (await readXlsx(bytes, { readStyles: true })).sheets[0].images[0]

for (const edit of ["", "twoCell", "oneCell", "absolute"]) {
test(`read/write two-cell container retains markers and editAs=${edit || "(omitted)"}`, async () => {
const image = await imageOf(await fixture(two(edit)))
assert.equal(image.anchor.kind, "twoCell")
assert.equal(image.anchor.editAs, edit || undefined)
assert.deepEqual(image.anchor.extent, extent)
assert.deepEqual(image.anchor.from, { row: 0, col: 1, rowOff: 257175, colOff: 133350 })
assert.deepEqual(image.anchor.to, { row: 3, col: 8, rowOff: 100965, colOff: 123158 })
assert.equal(image.width, extent.cx / 9525)
if (edit === "absolute") assert.deepEqual(image.anchor.position, { x: 10001, y: 20003 })
const again = await imageOf(
await writeXlsx({ sheets: [{ name: "Roundtrip", rows: [], images: [image] }] }),
)
assert.deepEqual(again.anchor, image.anchor)
assert.equal(again.width, image.width)
})
}
for (const [kind, xml] of [
["oneCell", one],
["absolute", absolute],
]) {
test(`${kind} container survives a read/write/read cycle without fabricated end marker`, async () => {
const image = await imageOf(await fixture(xml))
assert.equal(image.anchor.kind, kind)
assert.equal(image.anchor.to, undefined)
assert.deepEqual(image.anchor.extent, extent)
if (kind === "absolute") assert.deepEqual(image.anchor.position, { x: -3175, y: 19051 })
const again = await imageOf(
await writeXlsx({ sheets: [{ name: "Roundtrip", rows: [], images: [image] }] }),
)
assert.deepEqual(again.anchor, image.anchor)
})
}
test("one-cell extent is the container extent, not stale picture transform ext", async () => {
const image = await imageOf(
await fixture(
one.replace(`<xdr:ext cx="${extent.cx}" cy="${extent.cy}"/>`, '<xdr:ext cx="1" cy="2"/>'),
),
)
assert.deepEqual(image.anchor.extent, { cx: 1, cy: 2 })
assert.equal(image.width, 1 / 9525)
})
test("zero-size extents remain zero and do not turn into a default-size image", async () => {
const input = await imageOf(
await fixture(
one.replace(`<xdr:ext cx="${extent.cx}" cy="${extent.cy}"/>`, '<xdr:ext cx="0" cy="0"/>'),
),
)
assert.deepEqual(input.anchor.extent, { cx: 0, cy: 0 })
const output = await imageOf(
await writeXlsx({ sheets: [{ name: "Zero", rows: [], images: [input] }] }),
)
assert.deepEqual(output.anchor.extent, { cx: 0, cy: 0 })
})
for (const value of ["NaN", "Infinity", "-1", "9007199254740992", "1.5"]) {
test(`rejects invalid saved EMU extent ${value}`, async () => {
const image = await imageOf(await fixture(two().replace(`cx="${extent.cx}"`, `cx="${value}"`)))
assert.equal(image.anchor.extent, undefined)
})
}
test("explicit zero offsets and subpixel offsets survive serialization", async () => {
const image = await imageOf(await fixture(two("oneCell")))
image.anchor.from = { row: 0, col: 0, rowOff: 0, colOff: 1 }
image.anchor.to = { row: 1, col: 1, rowOff: 2, colOff: 0 }
const output = await imageOf(
await writeXlsx({ sheets: [{ name: "Tiny", rows: [], images: [image] }] }),
)
assert.deepEqual(
{ ...output.anchor.from, rowOff: output.anchor.from.rowOff ?? 0 },
image.anchor.from,
)
assert.deepEqual({ ...output.anchor.to, colOff: output.anchor.to.colOff ?? 0 }, image.anchor.to)
})
test("cloneSheet isolates all nested drawing coordinates", async () => {
const original = (await readXlsx(await fixture(two("absolute")))).sheets[0]
const cloned = cloneSheet(original, "Copy")
cloned.images[0].anchor.extent.cx++
cloned.images[0].anchor.position.x++
cloned.images[0].anchor.from.rowOff++
assert.equal(original.images[0].anchor.extent.cx, extent.cx)
assert.equal(original.images[0].anchor.position.x, 10001)
assert.equal(original.images[0].anchor.from.rowOff, 257175)
})
test("structuredClone preserves the full worker-safe geometry contract", async () => {
const image = await imageOf(await fixture(two("absolute")))
assert.deepEqual(structuredClone(image).anchor, image.anchor)
})
function styles(xfId = "1", fontId = "2") {
return `<styleSheet xmlns="${NS}/spreadsheetml/2006/main"><fonts count="3"><font><name val="Fallback"/><sz val="11"/></font><font><name val="Cell"/><sz val="8"/></font><font><name val="Normal Font"/><sz val="10"/><b/><color rgb="FF112233"/></font></fonts><fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="2"><xf fontId="0"/><xf fontId="${fontId}"/></cellStyleXfs><cellXfs count="1"><xf fontId="1" fillId="0" borderId="0" numFmtId="0"/></cellXfs><cellStyles count="1"><cellStyle name="Localized name" builtinId="0" xfId="${xfId}"/></cellStyles></styleSheet>`
}
test("Normal font follows builtinId -> xfId -> fontId, not fonts[0] or the first cell", async () => {
const book = await readXlsx(await fixture(two(), styles()), { readStyles: true })
assert.equal(book.defaultFont.name, "Normal Font")
assert.equal(book.defaultFont.size, 10)
assert.equal(book.defaultFont.bold, true)
assert.equal(book.sheets[0].cells.get("0,0").style.font.name, "Cell")
})
for (const [xf, font] of [
["999", "2"],
["-1", "2"],
["bad", "2"],
["1", "999"],
["1", "bad"],
]) {
test(`invalid Normal reference ${xf}/${font} falls back safely`, async () => {
const book = await readXlsx(await fixture(two(), styles(xf, font)), { readStyles: true })
assert.equal(book.defaultFont.name, "Fallback")
})
}
test("legacy caller-created image anchors remain supported", async () => {
const image = {
data: png,
type: "png",
anchor: { from: { row: 1, col: 2 }, to: { row: 4, col: 5 } },
width: 17,
height: 23,
}
const bytes = await writeXlsx({ sheets: [{ name: "Legacy", rows: [], images: [image] }] })
const saved = await imageOf(bytes)
assert.equal(saved.anchor.kind, "twoCell")
assert.deepEqual(saved.anchor.extent, { cx: 17 * 9525, cy: 23 * 9525 })
const xml = decode.decode(await new ZipReader(bytes).extract("xl/drawings/drawing1.xml"))
assert.ok(!xml.includes("NaN") && !xml.includes("undefined"))
})
8 changes: 8 additions & 0 deletions src/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,14 @@ export interface SheetImage {
type: "png" | "jpeg" | "gif" | "svg" | "webp"
/** Anchor to cell */
anchor: {
/** Original DrawingML container. Omitted by legacy caller-created models. */
kind?: "twoCell" | "oneCell" | "absolute"
/** Resizing behavior of a two-cell container; does not discard its markers. */
editAs?: "twoCell" | "oneCell" | "absolute"
/** Exact saved drawing extent in EMUs (914400 per inch), before display rounding. */
extent?: { cx: number; cy: number }
/** Sheet-relative position in EMUs for absolute placement. */
position?: { x: number; y: number }
from: { row: number; col: number; rowOff?: number; colOff?: number }
to?: { row: number; col: number; rowOff?: number; colOff?: number }
}
Expand Down
2 changes: 2 additions & 0 deletions src/sheet-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,8 @@ export function cloneSheet(sheet: Sheet, newName: string): Sheet {
const copy = { ...img, data: new Uint8Array(img.data) }
copy.anchor = { ...img.anchor, from: { ...img.anchor.from } }
if (img.anchor.to) copy.anchor.to = { ...img.anchor.to }
if (img.anchor.extent) copy.anchor.extent = { ...img.anchor.extent }
if (img.anchor.position) copy.anchor.position = { ...img.anchor.position }
return copy
})
}
Expand Down
68 changes: 68 additions & 0 deletions src/xlsx/drawing-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { SheetImage } from "../_types"
import type { XmlElement } from "../xml/parser"

type Layout = Pick<SheetImage["anchor"], "kind" | "editAs" | "extent" | "position">

function child(parent: XmlElement | undefined, name: string): XmlElement | undefined {
return parent?.children.find(
(node): node is XmlElement => typeof node !== "string" && (node.local || node.tag) === name,
)
}

function picture(anchor: XmlElement): XmlElement | undefined {
const direct = child(anchor, "pic")
if (direct) return direct
// Follow compatibility wrappers only. Grouped-child transforms use a
// different coordinate system and must not leak into the sheet anchor.
const pending = [...anchor.children].reverse()
while (pending.length) {
const node = pending.pop()
if (!node || typeof node === "string") continue
const local = node.local || node.tag
if (local === "pic") return node
if (local === "AlternateContent" || local === "Choice" || local === "Fallback") {
for (let index = node.children.length - 1; index >= 0; index--) {
pending.push(node.children[index]!)
}
}
}
return undefined
}

function pair(node: XmlElement | undefined, a: string, b: string): [number, number] | undefined {
if (!node || node.attrs[a] === undefined || node.attrs[b] === undefined) return undefined
if (!/^[+-]?\d+$/.test(node.attrs[a].trim()) || !/^[+-]?\d+$/.test(node.attrs[b].trim())) {
return undefined
}
const x = Number(node.attrs[a])
const y = Number(node.attrs[b])
return Number.isSafeInteger(x) && Number.isSafeInteger(y) ? [x, y] : undefined
}

/** Preserve file geometry in EMUs; DPI, zoom and grid rounding belong to the renderer. */
export function readDrawingLayout(anchor: XmlElement): Layout {
const local = anchor.local || anchor.tag
let kind: Layout["kind"]
if (local === "twoCellAnchor") kind = "twoCell"
else if (local === "oneCellAnchor") kind = "oneCell"
else if (local === "absoluteAnchor") kind = "absolute"
else return {}
const result: Layout = { kind }
const editAs = anchor.attrs["editAs"]
if (
kind === "twoCell" &&
(editAs === "oneCell" || editAs === "absolute" || editAs === "twoCell")
) {
result.editAs = editAs
}
const xfrm = child(child(picture(anchor), "spPr"), "xfrm")
const ext = kind === "twoCell" ? child(xfrm, "ext") : child(anchor, "ext")
const size = pair(ext, "cx", "cy")
if (size && size[0] >= 0 && size[1] >= 0) result.extent = { cx: size[0], cy: size[1] }
let positionNode: XmlElement | undefined
if (kind === "absolute") positionNode = child(anchor, "pos")
else if (editAs === "absolute") positionNode = child(xfrm, "off")
const pos = pair(positionNode, "x", "y")
if (pos) result.position = { x: pos[0], y: pos[1] }
return result
}
Loading
Loading