From 048d53bd800717f0a02d7fa9c6c349986b58bc23 Mon Sep 17 00:00:00 2001 From: syalr2 Date: Sun, 1 Nov 2026 12:44:15 -0500 Subject: [PATCH] Export exact-size yarn-colour stitch bitmaps --- README.md | 1 + app/globals.css | 14 +++++ components/export-tools.tsx | 94 +++++++++++++++++++++++++++++ e2e/editor.spec.ts | 117 ++++++++++++++++++++++++++++++++++++ lib/stitch-bitmap.test.ts | 76 +++++++++++++++++++++++ lib/stitch-bitmap.ts | 29 +++++++++ 6 files changed, 331 insertions(+) create mode 100644 lib/stitch-bitmap.test.ts create mode 100644 lib/stitch-bitmap.ts diff --git a/README.md b/README.md index 3d37dfe..7e367a4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Open the hosted chart maker at [knitplot.art](https://knitplot.art). Every non-A - Choose stranded, intarsia, or duplicate stitch instructions - Use Knit Mode with row and stitch progress, optional design-only cropping, and stitch markers - Download chart and preview PNGs or save an editable `.knitplot` project file +- Export an exact-size stitch bitmap PNG (one pixel per stitch, yarn colours only) for colourwork image import into other tools. Stitch actions and shaping are not encoded; no-stitch cells require an explicit fill-colour choice. ### OpenAI API key needed diff --git a/app/globals.css b/app/globals.css index 6d971dd..0e7f9e3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -489,6 +489,20 @@ button:disabled { border-top: 1px solid var(--line); margin: 5px 3px; } +.file-menu-bitmap-options { + background: #f3f5f3; + border-radius: 7px; + margin: 0 4px 6px; + padding: 8px; + font-size: 12px; +} +.file-menu-bitmap-options p { + margin: 0 0 8px; +} +.file-menu-bitmap-options label { + display: grid; + gap: 6px; +} .file-menu-section-label { color: #96907f; font-size: 9px; diff --git a/components/export-tools.tsx b/components/export-tools.tsx index 2b53e7b..f3c1546 100644 --- a/components/export-tools.tsx +++ b/components/export-tools.tsx @@ -13,6 +13,7 @@ import { chartCellDisplayColor, } from "@/lib/chart"; import { readProject } from "@/lib/project-file"; +import { stitchBitmap } from "@/lib/stitch-bitmap"; type Props = { document: ChartDocument; @@ -206,7 +207,12 @@ export function ExportTools({ const menuRef = useRef(null); const [open, setOpen] = useState(false); const [choosingChartImage, setChoosingChartImage] = useState(false); + const [confirmingBitmap, setConfirmingBitmap] = useState(false); + const [noStitchColorId, setNoStitchColorId] = useState(""); const [message, setMessage] = useState(""); + const hasNoStitch = document.stitchActions?.some((row) => + row.includes("no-stitch"), + ); useEffect(() => { if (!open) return; @@ -251,6 +257,30 @@ export function ExportTools({ } } + async function downloadBitmap() { + try { + const bitmap = stitchBitmap(document, noStitchColorId); + const canvas = window.document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("The stitch bitmap could not be created."); + const image = context.createImageData(bitmap.width, bitmap.height); + image.data.set(bitmap.pixels); + context.putImageData(image, 0, 0); + downloadBlob( + await canvasBlob(canvas), + fileName(document.name, "stitch-bitmap.png"), + ); + setConfirmingBitmap(false); + setMessage( + `Stitch bitmap downloaded: ${bitmap.width} × ${bitmap.height} pixels.`, + ); + } catch { + setMessage("The stitch bitmap could not be created."); + } + } + async function downloadPreview() { const canvas = previewCanvasRef.current; if ( @@ -315,6 +345,8 @@ export function ExportTools({ onClick={() => { setOpen((value) => !value); setChoosingChartImage(false); + setConfirmingBitmap(false); + setNoStitchColorId(""); setMessage(""); }} > @@ -413,6 +445,68 @@ export function ExportTools({ ) : null} + + {confirmingBitmap ? ( +
+

+ Yarn colours only. Stitch actions and shaping are not + included. +

+ {hasNoStitch ? ( + + ) : null} + + +
+ ) : null} diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index c022378..0b05758 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -1,4 +1,10 @@ import { test, expect } from "@playwright/test"; +import { + cloneDocument, + defaultDocument, + createStitchActions, +} from "../lib/chart"; +import { newChartTab, workspaceSave } from "../lib/workspace"; test.beforeEach(async ({ page }) => { // Each test runs in an isolated browser context, with no user's stored charts. @@ -111,3 +117,114 @@ test("chart dimensions allow typing intermediate values and reset on tab changes await page.getByRole("button", { name: "+ New chart", exact: true }).click(); await expect(rows).toHaveValue("14"); }); + +for (const structural of [false, true]) { + test(`stitch bitmap exports exact yarn pixels${structural ? " with explicit no-stitch handling" : " at 114 × 150"}`, async ({ + page, + }) => { + const doc = cloneDocument(defaultDocument); + doc.width = 114; + doc.height = 150; + doc.palette = [ + { id: "pink", hex: "#ff809a" }, + { id: "green", hex: "#123456" }, + ]; + doc.cells = Array.from({ length: 150 }, (_, row) => + Array.from({ length: 114 }, (_, column) => + (row + column) % 3 === 0 ? "pink" : "green", + ), + ); + if (structural) { + doc.stitchActions = createStitchActions(114, 150); + doc.stitchActions[0][0] = "no-stitch"; + doc.stitchActions[0][1] = "m1l"; + doc.stitchColors = { m1l: "#abcdef" }; + } + await page.addInitScript( + (workspace) => { + localStorage.setItem("colorwork-chart-v1", JSON.stringify(workspace)); + }, + workspaceSave([newChartTab(doc, "bitmap")], "bitmap"), + ); + await page.goto("/"); + await page.getByRole("button", { name: "File", exact: true }).click(); + const downloadEvent = structural ? null : page.waitForEvent("download"); + await page + .getByRole("menuitem", { + name: "Download stitch bitmap · 1 pixel per stitch", + exact: true, + }) + .click(); + let download; + if (structural) { + await expect( + page.getByText( + "Yarn colours only. Stitch actions and shaping are not included.", + { exact: true }, + ), + ).toBeVisible(); + const confirm = page.getByRole("menuitem", { + name: "Download colour-only bitmap", + exact: true, + }); + await expect(confirm).toBeDisabled(); + await page.getByLabel("Fill no-stitch cells with").selectOption("green"); + const event = page.waitForEvent("download"); + await confirm.click(); + download = await event; + } else { + download = await downloadEvent!; + } + expect(download.suggestedFilename()).toMatch(/\.stitch-bitmap\.png$/); + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); + const result = await page.evaluate( + async ({ base64, structural }) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const pixels = context.getImageData( + 0, + 0, + image.width, + image.height, + ).data; + let mismatches = 0; + const colors = new Set(); + for (let row = 0; row < image.height; row++) { + for (let column = 0; column < image.width; column++) { + const offset = (row * image.width + column) * 4; + const actual = Array.from(pixels.slice(offset, offset + 4)).join( + ",", + ); + const pink = + (row + column) % 3 === 0 && + !(structural && row === 0 && column === 0); + if (actual !== (pink ? "255,128,154,255" : "18,52,86,255")) + mismatches++; + colors.add(actual); + } + } + return { + width: image.width, + height: image.height, + mismatches, + colours: colors.size, + }; + }, + { base64: Buffer.concat(chunks).toString("base64"), structural }, + ); + expect(result).toEqual({ + width: 114, + height: 150, + mismatches: 0, + colours: 2, + }); + }); +} diff --git a/lib/stitch-bitmap.test.ts b/lib/stitch-bitmap.test.ts new file mode 100644 index 0000000..edcb5f6 --- /dev/null +++ b/lib/stitch-bitmap.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { cloneDocument, defaultDocument, createStitchActions } from "./chart"; +import { stitchBitmap } from "./stitch-bitmap"; + +function fixture(width = 114, height = 150) { + const doc = cloneDocument(defaultDocument); + doc.width = width; + doc.height = height; + doc.palette = [ + { id: "pink", hex: "#ff809a" }, + { id: "green", hex: "#123456" }, + ]; + doc.cells = Array.from({ length: height }, (_, row) => + Array.from({ length: width }, (_, column) => + (row + column) % 3 === 0 ? "pink" : "green", + ), + ); + return doc; +} + +test("bitmap is exactly one opaque palette-coloured pixel per stitch, in chart order", () => { + for (const [width, height] of [ + [114, 150], + [200, 150], + [18, 14], + ]) { + const doc = fixture(width, height); + const before = JSON.stringify(doc); + const result = stitchBitmap(doc); + assert.equal(result.width, width); + assert.equal(result.height, height); + assert.equal(result.pixels.length, width * height * 4); + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const offset = (row * width + column) * 4; + assert.deepEqual( + Array.from(result.pixels.slice(offset, offset + 4)), + doc.cells[row][column] === "pink" + ? [255, 128, 154, 255] + : [18, 52, 86, 255], + ); + } + } + assert.equal(JSON.stringify(doc), before); + } +}); + +test("gauge and stitch display colours never affect yarn pixels", () => { + const doc = fixture(3, 2); + const before = stitchBitmap(doc); + doc.gaugeStitches = 10; + doc.gaugeRows = 40; + doc.stitchActions = createStitchActions(3, 2); + doc.stitchActions[0][0] = "m1l"; + doc.stitchActions[1][1] = "purl"; + doc.stitchColors = { m1l: "#ffffff", purl: "#000000" }; + doc.stitchCellColors = [ + ["#abcdef", null, null], + [null, "#ffffff", null], + ]; + assert.deepEqual(stitchBitmap(doc), before); +}); + +test("no-stitch cells require an explicit palette colour and do not change dimensions", () => { + const doc = fixture(3, 2); + doc.stitchActions = createStitchActions(3, 2); + doc.stitchActions[0][0] = "no-stitch"; + assert.throws(() => stitchBitmap(doc), /Choose a yarn colour/); + assert.throws(() => stitchBitmap(doc, "missing"), /Choose a yarn colour/); + const bitmap = stitchBitmap(doc, "green"); + assert.deepEqual(Array.from(bitmap.pixels.slice(0, 4)), [18, 52, 86, 255]); + assert.equal(bitmap.width, 3); + assert.equal(bitmap.height, 2); + assert.equal(doc.stitchActions[0][0], "no-stitch"); +}); diff --git a/lib/stitch-bitmap.ts b/lib/stitch-bitmap.ts new file mode 100644 index 0000000..5e1df74 --- /dev/null +++ b/lib/stitch-bitmap.ts @@ -0,0 +1,29 @@ +import { type ChartDocument, stitchActionAt } from "./chart"; + +/** Raw yarn colours, in chart order: one opaque pixel per chart cell. */ +export function stitchBitmap( + document: ChartDocument, + noStitchColorId?: string, +) { + const palette = new Map( + document.palette.map(({ id, hex }) => [ + id, + [1, 3, 5].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16), + ), + ]), + ); + const pixels = new Uint8ClampedArray(document.width * document.height * 4); + for (let row = 0; row < document.height; row++) { + for (let column = 0; column < document.width; column++) { + const id = + stitchActionAt(document, row, column) === "no-stitch" + ? noStitchColorId + : document.cells[row][column]; + const rgb = id ? palette.get(id) : undefined; + if (!rgb) throw new Error("Choose a yarn colour for no-stitch cells."); + pixels.set([...rgb, 255], (row * document.width + column) * 4); + } + } + return { width: document.width, height: document.height, pixels }; +}