Nobody frames a screenshot of a cross-stitch app. The artifact that matters is a printed chart taped to the wall next to the couch, and print is unforgiving in ways screens are not. Symbols must stay legible at 6 points. Page edges must not eat stitches. And the legend has to answer the only question that costs money: how many skeins do I buy?
I work on Threaded (https://threaded.diy), a browser studio that turns photos into cross-stitch and needlepoint patterns. Our first post walked the image pipeline; this one is about the PDF at the end of it. All the code is real.
Ninety symbols, and every one has to survive a laser printer
Each thread color in a chart gets a symbol. Screens make this easy (just color the cells); paper makes it hard, because your chart prints in black and white more often than not. We keep a fixed glyph alphabet:
export const LABEL_CHARS =
"●■▲▼◀▶◆⬟⬢⬣⯁○□△▽◁▷◇⬠⬡◯◐◑◒◓◧◨◩◪⬗⬖★☆✦✧✪✯✶✷❂✚✕✖✜⊕⊗⊙⊠♥♦♣♠♡♢♧♤⬮⬯⚑⚐☀☁☂☘✔✿ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ⋈⋂⋃⊖⊘⋉⋊≈$%#@&!?"
.split("");
Ninety-ish glyphs, ordered so the most visually distinct pairs land first: filled vs. outline (●/○), orthogonal vs. diagonal (■/◆), half-filled circles (◐◑◒◓) for the middle of the alphabet where confusion is most likely. Filled and outline variants of the same shape are never assigned to adjacent colors - adjacent palette entries are usually near each other in color space, and near-identical colors with near-identical symbols is how miscounts happen.
The symbol ink also adapts to the cell. A dark thread gets a white glyph, a light thread gets black, decided by plain luma:
export function isDarkColor(hex: string): boolean {
const v = hex.replace("#", "");
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
}
Yes, it is the ITU-R 601 luma from 1982. It outperforms fancier contrast formulas here because the decision is binary and the input is a solid fill, not text on a gradient.
The half-pixel grid line trick
Grid lines help you count; grid lines also drown symbols when cells get small. Two rules came out of real print tests:
export function shouldDrawGridLines(cellSize: number): boolean {
return cellSize >= 3;
}
Below 3 pixels per cell the lines cost more legibility than they add, so they vanish. And when lines do draw, they are stroked at x * cellSize + 0.5 - the classic canvas half-pixel offset. Without it a 1px stroke straddles two physical pixels and prints as a fuzzy gray 2px line. With it, the line lands exactly on one row of device pixels. This one character (+ 0.5) is the difference between a chart that looks printed and a chart that looks photocopied.
Skein math, or: the legend is a shopping list
The thread table is the page stitchers photograph before driving to the craft store. For every color it shows symbol, brand and code (DMC 310, Anchor 403, ...), stitch count, yardage, and skeins. The skein estimate is where most apps hand-wave; we made the constants explicit and honest:
export const SKEIN_PIECES = 17; // a pulled-apart skein yields ~17 pieces
export const USABLE_PER_PIECE_IN = 15; // usable inches per piece after tails
export const THREAD_PER_STITCH_NUM = 6; // inches of floss per stitch = 6 / fabric count
export const STRANDS_PER_SKEIN = 6;
export const DEFAULT_STRANDS = 2; // most people stitch with 2 of 6 strands
export const YARDS_PER_SKEIN = 8.7; // a standard DMC skein
The math: one stitch on 14-count aida at 2 strands consumes 6/14 inches of a strand. A skein gives you 17 pieces x 15 usable inches x (6 strands / 2 used). So stitches-per-skein is computed per color, divided into that color's actual stitch count, and rounded UP - because "you need 1.1 skeins" means you buy 2, and a chart that tells you 1 sends you back to the store mid-project. Every constant is wrong for somebody (needlepointers on 13-mesh use more thread, some stitchers waste less), which is why the fabric count and strand count are inputs, not assumptions baked into the output.
Tiling 262,144 cells across Letter pages
A 512x512 pattern does not fit on a page. It becomes a poster you assemble:
export const SECTION_COUNTS = [1, 4, 9, 16]; // 1x1, 2x2, 3x3, or 4x4 pages
export const DPI = 150; // raster resolution per section
The grid splits into tiles with integer boundary math (x0 = floor(col * grid.w / cols)) so no stitch ever lands on two pages or none. Each section renders to canvas at 150 DPI, and the PDF assembles the pages in reading order with the section grid labeled on the key page. The document also embeds the Merriweather family directly, because a PDF that substitutes fonts at the print shop is a PDF that lies about symbol alignment.
What we deliberately did not build
No overlap duplication between sections. Overlaps sound helpful until you realize every duplicated stitch is a stitch you can count twice; clean tile boundaries plus a labeled key page do the job with zero ambiguity. No color blocks behind symbols on the key page (wastes toner, and the symbol IS the reference). No vector charts (canvas raster at 150 DPI prints identically everywhere and renders in milliseconds on a five-year-old phone).
Try it at https://threaded.diy - the full editor is free, so you can run your own photo through the pipeline and watch the chart take shape before deciding whether the printable PDF is worth Pro. If your estimate differs, the comments are open; the constants are right there to argue with.
Top comments (0)