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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions components/export-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -206,7 +207,12 @@ export function ExportTools({
const menuRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -315,6 +345,8 @@ export function ExportTools({
onClick={() => {
setOpen((value) => !value);
setChoosingChartImage(false);
setConfirmingBitmap(false);
setNoStitchColorId("");
setMessage("");
}}
>
Expand Down Expand Up @@ -413,6 +445,68 @@ export function ExportTools({
</button>
</div>
) : null}
<button
role="menuitem"
aria-expanded={confirmingBitmap}
onClick={() => {
setMessage("");
if (hasStructuralStitches(document)) {
setConfirmingBitmap((value) => !value);
} else {
void downloadBitmap();
}
}}
>
Download stitch bitmap · 1 pixel per stitch
</button>
{confirmingBitmap ? (
<div
className="file-menu-bitmap-options"
role="group"
aria-label="Stitch bitmap options"
>
<p>
Yarn colours only. Stitch actions and shaping are not
included.
</p>
{hasNoStitch ? (
<label>
Fill no-stitch cells with
<select
value={noStitchColorId}
onChange={(event) =>
setNoStitchColorId(event.target.value)
}
>
<option value="">Choose a yarn colour</option>
{document.palette.map((color, index) => (
<option key={color.id} value={color.id}>
Colour {index + 1} · {color.hex}
</option>
))}
</select>
</label>
) : null}
<button
role="menuitem"
disabled={Boolean(
hasNoStitch &&
!document.palette.some(
(color) => color.id === noStitchColorId,
),
)}
onClick={() => void downloadBitmap()}
>
Download colour-only bitmap
</button>
<button
role="menuitem"
onClick={() => setConfirmingBitmap(false)}
>
Cancel
</button>
</div>
) : null}
<button role="menuitem" onClick={downloadPreview}>
Download knitted preview
</button>
Expand Down
117 changes: 117 additions & 0 deletions e2e/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string>();
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,
});
});
}
76 changes: 76 additions & 0 deletions lib/stitch-bitmap.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
29 changes: 29 additions & 0 deletions lib/stitch-bitmap.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading