Tag: Threads

Threads – Changes

I had a few issues that prevented me from using Threads in its current form. I suspect my use case differs from the one they are coding to – I have resources and no need to limit myself to a small number of thread colors. Also, a hidden UI element seems to hide a few neighboring elements that I wanted to use.

Updates:

app.py
@@ -12,6 +12,7 @@ family of projects integrated:
“””
import base64
import json
+import logging
import os
import sys
import tempfile
@@ -36,8 +37,13 @@ from inkstitchlib import ai_naming
from inkstitchlib import density as density_map
from inkstitchlib import presets, sketch as sketch_mod

-JOBS = os.path.join(tempfile.gettempdir(), ‘stitchforge_jobs’)
-os.makedirs(JOBS, exist_ok=True)
+#JOBS = os.path.join(tempfile.gettempdir(), ‘stitchforge_jobs’)
+#os.makedirs(JOBS, exist_ok=True)
+from pathlib import Path
+
+JOBS = Path(__file__).resolve().parent / “stitchforge_jobs”
+JOBS.mkdir(parents=True, exist_ok=True)
+

app = FastAPI(title=’StitchForge’)

@@ -276,6 +282,8 @@ def studio():
@app.post(‘/api/analyze’)
async def analyze(image: UploadFile = File(…), colors: int = Form(3)):
“””Separate the image into colour layers and report stroke widths.”””
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
job = uuid.uuid4().hex[:12]
d = _job_dir(job, must_exist=False)
os.makedirs(d, exist_ok=True)
@@ -315,6 +323,8 @@ async def digitize(job: str = Form(…), colors: int = Form(3),
cap_mode: bool = Form(False),
trim_dist: float = Form(2.0),
fabric: str = Form(”), machine: str = Form(”)):
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
d = _job_dir(job)
src = None
for fn in os.listdir(d):
@@ -716,6 +726,8 @@ async def vectorize(image: UploadFile = File(…), colors: int = Form(4),
“””Look at the artwork and return editable vector layers — connected
regions as single objects, similar round shapes grouped, one layer per
large shape. No stitches are made until /api/stitch_layers.”””
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
job = uuid.uuid4().hex[:12]
d = _job_dir(job, must_exist=False)
os.makedirs(d, exist_ok=True)
@@ -723,7 +735,7 @@ async def vectorize(image: UploadFile = File(…), colors: int = Form(4),
with open(src, ‘wb’) as f:
f.write(await image.read())
try:
– lyrs, w, h = veclayers.vectorize(src, max(1, min(8, colors)),
+ lyrs, w, h = veclayers.vectorize(src, colors,
max(10.0, min(400.0, width_mm)))
except veclayers.LayerError as e:
raise HTTPException(400, str(e))
@@ -766,12 +778,20 @@ async def stitch_layers(data: dict):
“””Sew the arranged layers into a design (this is when stitches exist).”””
lyrs = data.get(‘layers’) or []
if not isinstance(lyrs, list) or not lyrs:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected: no layers to stitch’)
raise HTTPException(400, ‘no layers to stitch’)
– if len(lyrs) > 80:
– raise HTTPException(400, ‘too many layers (max 80)’)
+ max_layers = 512
+ if len(lyrs) > max_layers:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected: %d layers exceeds the limit of %d’,
+ len(lyrs), max_layers)
+ raise HTTPException(400, ‘too many layers (max %d)’ % max_layers)
try:
pat, block_layers = veclayers.stitch(lyrs, settings=_stitch_settings(data))
except veclayers.LayerError as e:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected (%d layers): %s’, len(lyrs), e)
raise HTTPException(400, str(e))
except Exception as e:
traceback.print_exc()
@@ -796,8 +816,9 @@ def fills_list():
return [{‘id’: k, ‘name’: patterns.FILL_LABELS[k]} for k in veclayers.FILL_METHODS]

-_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), ‘stitchforge_fills’)
–
+#_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), ‘stitchforge_fills’)
+_FILL_PREVIEW_DIR = Path(__file__).resolve().parent / “stitchforge_fills”
+_FILL_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)

@app.get(‘/api/fill_preview/{name}.png’)
def fill_preview(name: str):

digitizer/core.py

@@ -610,9 +610,10 @@ def smooth(p, k=9):
if len(p) < k or k < 3:
return p
ker = np.ones(k) / k
+ padded = np.pad(p, ((k // 2, (k – 1) // 2), (0, 0)), mode=’edge’)
out = p.copy()
– out[:, 0] = np.convolve(p[:, 0], ker, mode=’same’)
– out[:, 1] = np.convolve(p[:, 1], ker, mode=’same’)
+ out[:, 0] = np.convolve(padded[:, 0], ker, mode=’valid’)
+ out[:, 1] = np.convolve(padded[:, 1], ker, mode=’valid’)
out[:2], out[-2:] = p[:2], p[-2:] # don’t drag the tips inward
return out

@@ -1041,7 +1042,8 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,
if not heavy_underlay and mode == ‘heavy’:
mode = ‘auto’
if _do(phase, UNDER) and mode != ‘none’:
– sew_edge_run(sewer, poly, inset=min(0.6, border_w), travel=travel)
+ inset = min(0.6, border_w) if border_w > 0 else 0.6
+ sew_edge_run(sewer, poly, inset=inset, travel=travel)
ax = principal_angle(poly)
if mode == ‘heavy’:
for ang in (ax + 45, ax – 45):
@@ -1054,7 +1056,7 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,
if not _do(phase, TOP):
return

– ring = satin_ring(poly, border_w, spacing, start=sewer.pos)
+ ring = satin_ring(poly, border_w, spacing, start=sewer.pos) if border_w > 0 else None
core = poly.buffer(-border_w * 0.72) if ring else poly
if core.geom_type == ‘MultiPolygon’:
core = max(core.geoms, key=lambda g: g.area)

digitizer/engine.py

@@ -24,7 +24,7 @@ class Params:
underlay_spacing: float = 2.5
min_fill_area: float = 0.20
min_blob_area: float = 2.0 # below this, satin-ing a blob isn’t worth it
– border_width: float = 1.0 # satin outline band around blobs
+ border_width: float = 0.0 # optional satin outline band; zero disables it
fill_method: str = ‘tatami’ # tatami | contour | circular (Ink/Stitch fills)
fill_angle: float = 65.0
heavy_underlay: bool = True
@@ -155,10 +155,7 @@ def build_pattern(layers, canvas_mm, width_px, p: Params):
if phase != core.UNDER:
fill_methods.sew_area(s, g, p.fill_method, p.fill_angle,
p.row_spacing, p.max_stitch, travel=travel)
– # too wide to satin across, but it still needs a hard edge:
– # a satin outline over the fill is what stops a torso or a
– # head reading as ragged
– if q.area >= p.min_blob_area * 2:
+ if p.border_width > 0 and q.area >= p.min_blob_area * 2:
ring = core.satin_ring(g, p.border_width, p.row_spacing)
if ring:
core.sew_ring(s, ring, travel)

digitizer/segment.py

@@ -4,6 +4,7 @@ import cv2
from PIL import Image

MAX_WORK_PX = 1400 # working raster; bigger only costs time
+MAX_COLORS = 32

def load(path):
@@ -43,7 +44,7 @@ def quantize(rgba, n_colors, fg=None):
pix = rgb[fg]
if len(pix) < 16:
raise ValueError(‘the image looks empty once the background is removed’)
– n_colors = int(max(1, min(n_colors, 8, len(np.unique(pix, axis=0)))))
+ n_colors = int(max(1, min(n_colors, MAX_COLORS, len(np.unique(pix, axis=0)))))
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
_, lbl, centers = cv2.kmeans(pix, n_colors, None, crit, 4,
cv2.KMEANS_PP_CENTERS)

inkstitchlib/layers.py

@@ -21,7 +21,7 @@ from shapely.geometry import Polygon, MultiPolygon, LineString
from digitizer import segment, core
from . import fills, patterns

-DEFAULT_PARAMS = {‘stitch’: ‘auto’, ‘fill_method’: ‘tatami’,
+DEFAULT_PARAMS = {‘stitch’: ‘fill’, ‘fill_method’: ‘tatami’,
‘angle’: ‘auto’, ‘density’: 0.4, ‘border_mm’: 1.0}

inkstitchlib/worksheet.py

@@ -444,6 +444,7 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,
_text(c, cols[‘Name’], y, _fit(c, name, name_w, body, fs), fs)
_text(c, cols[‘Chart’], y, chart, fs, align=’right’)
y -= lh
+ next_block = min(len(blocks), i + 1) if blocks else 0
c.setStrokeColor(black)
c.setLineWidth(0.8)
c.line(col_x, y + lh – 3.5, x1, y + lh – 3.5)
@@ -455,6 +456,75 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,
_text(c, x0 + 150, fy, ‘Design last saved : %s’ % _stamp(saved_at or now), 7.5)
_text(c, x0 + 345, fy, ‘Date printed: %s’ % _stamp(now), 7.5)
_text(c, x1 – 3, fy, ‘Page %d of %d’ % (c.getPageNumber(), pages), 7.5, align=’right’)
+ return next_block
+
+
+def _production_stop_page(c, blocks, layers, thread_matches, start, design_name,
+ client, saved_at, pages):
+ “””Draw a full-width continuation page for the production stop sequence.”””
+ W, H = letter
+ body = c._body
+ m = 0.22 * 72
+ x0, x1, y0, y1 = m, W – m, m, H – m
+ c.setStrokeColor(black)
+ c.setLineWidth(0.8)
+ c.rect(x0, y0, x1 – x0, y1 – y0)
+
+ _text(c, x0 + 8, y1 – 20, ‘Production Worksheet’, 12.5, bold=True, color=c._accent)
+ _text(c, x0 + 8, y1 – 35, design_name, 9)
+ _text(c, x0 + 8, y1 – 55, ‘Stop Sequence (continued)’, 10, bold=True)
+ c.line(x0, y1 – 64, x1, y1 – 64)
+
+ cols = {‘#’: x0 + 8, ‘N#’: x0 + 35, ‘Color’: x0 + 66, ‘St.’: x0 + 151,
+ ‘Code’: x0 + 158, ‘Name’: x0 + 245, ‘Chart’: x1 – 8}
+ y = y1 – 82
+ for key, align in ((‘#’, ‘l’), (‘N#’, ‘l’), (‘Color’, ‘l’), (‘St.’, ‘r’),
+ (‘Code’, ‘l’), (‘Name’, ‘l’), (‘Chart’, ‘r’)):
+ _text(c, cols[key], y, key, 8, bold=True,
+ align=’right’ if align == ‘r’ else ‘left’)
+ c.line(x0 + 5, y – 4, x1 – 5, y – 4)
+ y -= 16
+
+ unique = []
+ for layer in layers:
+ if layer[‘hex’].upper() not in unique:
+ unique.append(layer[‘hex’].upper())
+ index = start
+ while index < len(blocks) and y >= y0 + 30:
+ block = blocks[index]
+ layer = layers[index] if index < len(layers) else {
+ ‘hex’: ‘#888888’, ‘name’: ‘Colour %d’ % (index + 1)}
+ needle = unique.index(layer[‘hex’].upper()) + 1 \
+ if layer[‘hex’].upper() in unique else index + 1
+ match = thread_matches[index] if thread_matches and index < len(thread_matches) else None
+ code = (match.get(‘thread_number’) or ”) if match else ”
+ name = (match.get(‘thread_name’) if match else None) or layer.get(‘name’, ”)
+ chart = match.get(‘palette’, ‘Default’) if match else ‘Default’
+ fs = 7.5
+ if c.stringWidth(chart, body, fs) > 70:
+ chart = chart.split(‘ ‘)[0]
+ chart = _fit(c, chart, 70, body, fs)
+ name_w = cols[‘Chart’] – c.stringWidth(chart, body, fs) – cols[‘Name’] – 8
+
+ _text(c, cols[‘#’], y, ‘%d.’ % (index + 1), 8)
+ _text(c, cols[‘N#’], y, str(needle), 8)
+ c.setFillColor(HexColor(layer[‘hex’]))
+ c.setStrokeColor(black)
+ c.rect(cols[‘Color’], y – 2, 72, 9, fill=1)
+ _text(c, cols[‘St.’], y, ‘{:,}’.format(block[‘stitches’]), fs, align=’right’)
+ _text(c, cols[‘Code’], y, _fit(c, code, 80, body, fs), fs)
+ _text(c, cols[‘Name’], y, _fit(c, name, name_w, body, fs), fs)
+ _text(c, cols[‘Chart’], y, chart, fs, align=’right’)
+ y -= 14
+ index += 1
+
+ c.line(x0 + 5, y + 10, x1 – 5, y + 10)
+ now = datetime.datetime.now()
+ _text(c, x0 + 3, y0 + 4, ‘Authors:%s’ % ((‘ ‘ + client) if client else ”), 7.5)
+ _text(c, x0 + 150, y0 + 4, ‘Design last saved : %s’ % _stamp(saved_at or now), 7.5)
+ _text(c, x1 – 3, y0 + 4, ‘Page %d of %d’ % (c.getPageNumber(), pages),
+ 7.5, align=’right’)
+ return index

def _quote_page(c, stitches, quote_params, design_name, client=”, notes=”):
@@ -497,10 +567,17 @@ def build_production(path, pattern, report, layers, thread_matches=None,
c = Canvas(path, pagesize=letter)
c.setTitle(‘%s — production worksheet’ % design_name)
_apply_theme(c, theme, logo_path)
– _production_page(c, pattern, report, layers, thread_matches, preview_png,
– design_name, client, saved_at, title=title,
– pages=2 if quote_params else 1)
+ blocks = block_stats(pattern)
+ continuation_pages = int(math.ceil(max(0, len(blocks) – 32) / 48.0))
+ pages = 1 + continuation_pages + (1 if quote_params else 0)
+ next_block = _production_page(c, pattern, report, layers, thread_matches, preview_png,
+ design_name, client, saved_at, title=title, pages=pages)
c.showPage()
+ while next_block < len(blocks):
+ _apply_theme(c, theme, logo_path)
+ next_block = _production_stop_page(c, blocks, layers, thread_matches, next_block,
+ design_name, client, saved_at, pages)
+ c.showPage()
if quote_params:
_apply_theme(c, theme, logo_path)
_quote_page(c, report.get(‘stitches’, 0), quote_params, design_name)
@@ -582,6 +659,12 @@ def build(path, pattern, report, layers, thread_matches=None, preview_png=None,
c.line(MARGIN, y, PAGE_W – MARGIN, y)
y -= 16
for i, L in enumerate(layers):
+ if y < MARGIN + 24:
+ _footer(c)
+ c.showPage()
+ _apply_theme(c, theme, logo_path)
+ _header(c, design_name, ‘Thread sequence (continued)’)
+ y = PAGE_H – MARGIN – 48
c.setFillColor(HexColor(L[‘hex’]))
c.setStrokeColor(LINE)
c.rect(MARGIN, y – 3, 8 * mm, 5 * mm, fill=1)

static/clients.html

@@ -11,7 +11,7 @@
–bg:#f6f7f9; –surface:#ffffff; –ink:#1a1d23; –muted:#6b7280;
–line:#e9eaee; –line2:#e2e4e9;
–accent:#2563eb; –accent-press:#1d4ed8; –accent-soft:#eef4ff;
– –stage:#181c26; –ok:#188652; –warn:#a8620a; –warnbg:#fdf5e7; –danger:#c2410c;
+ –stage:#cccccc; –ok:#188652; –warn:#a8620a; –warnbg:#fdf5e7; –danger:#c2410c;
–r:16px; –shadow:0 1px 2px rgba(16,24,40,.05),0 8px 28px rgba(16,24,40,.07);
}
*{box-sizing:border-box}

static/index.html

@@ -294,7 +294,7 @@
details.sec[open]>summary::after{transform:rotate(-135deg)}
details.sec>*:not(summary){margin-bottom:6px}
details.sec>div:last-child,details.sec>p:last-child{margin-bottom:14px}
– details.sec:has(> .hide){display:none}
+ details.sec:has(> .hide):not(:has(> :not(summary):not(.hide))){display:none}
details.sec label{margin-top:8px}
details:not(.sec){background:var(–panel2);box-shadow:none;margin-top:10px}
details:not(.sec) summary{color:var(–ink)}
@@ -644,7 +644,7 @@
<div class=”unit”><input type=”number” id=”objGradTo” value=”1.5″ step=”0.1″ min=”0.3″ max=”4″><span>mm</span></div>
<div class=”tglrow”><span>Reverse direction</span><label class=”tog”><input type=”checkbox” id=”objGradFlip”><i></i></label></div>
</div>
– <div class=”tglrow”><span>Satin border</span><label class=”tog”><input type=”checkbox” id=”objBorder” checked><i></i></label></div>
+ <div class=”tglrow”><span>Satin border</span><label class=”tog”><input type=”checkbox” id=”objBorder”><i></i></label></div>
</details>
</div>
<details class=”sec” open><summary>Density Control<i class=”qhelp” title=”On the inside of a bend satin needle points crowd together. Row shortening pulls every other one back once the inner step drops below the trigger × the outer step.”>?</i></summary>
@@ -819,10 +819,7 @@
<option value=”sketch”>Sketch — one-colour outline</option>
</select></div>
<div id=”colorsWrap”><label>Thread colours</label>
– <select id=”colors”>
– <option>2</option><option selected>3</option><option>4</option>
– <option>5</option><option>6</option>
– </select></div>
+ <input id=”colors” type=”number” min=”2″ max=”32″ value=”3″></div>
</div>
<details class=”sec” open><summary>Fill Settings</summary>
<div id=”setDigitize”>
@@ -1009,8 +1006,8 @@
<div class=”row”>
<div><label>Stitch</label>
<select id=”vlStitch”>
– <option value=”auto” selected>Auto (satin/fill + border)</option>
– <option value=”fill”>Fill only</option>
+ <option value=”auto”>Auto (satin/fill + border)</option>
+ <option value=”fill” selected>Fill only</option>
<option value=”outline”>Satin outline</option>
<option value=”run”>Running outline</option>
<option value=”bean”>Bean-stitch outline</option>
@@ -3747,7 +3744,7 @@ function mmPos(el, e) { const p = worldPos(el, e); return [p[0] / PXMM, p[1] / P
const RUNS = [‘run’, ‘bean’, ‘satin’, ‘estitch’, ‘triangle’, ‘cross’, ‘motif’];
const DECOR = [‘estitch’, ‘triangle’, ‘cross’, ‘motif’];
const objDef = { color: ‘#1A3B69’, filled: true, run: ‘run’, pattern: ‘tatami’, angle: ‘auto’, density: 0.4, satinDensity: 0.32,
– border: true, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,
+ border: false, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,
underlay: ‘auto’, split: true, splitMax: 7, stagger: true, cycles: 4, amount: 0.3, gradient: false, gradTo: 1.5, gradFlip: false,
shortFrac: 0.45, underlays: null };
const UL_DEFAULT = { auto: [{ type: ‘contour’, len_mm: 1.5 }, { type: ‘center’, len_mm: 2.5 }], light: [{ type: ‘contour’, len_mm: 1.5 }], heavy: [{ type: ‘contour’, len_mm: 1.5 }, { type: ‘zigzag’, len_mm: 3 }], none: [] };
@@ -3835,7 +3832,7 @@ function syncObjBox() {
d.pattern = p.fill_method || ‘tatami’;
d.angle = p.angle === undefined ? ‘auto’ : p.angle;
if (!d.filled || isLine) d.satinDensity = p.density || 0.32; else d.density = p.density || 0.4;
– d.border = p.stitch !== ‘fill’;
+ d.border = p.stitch === ‘auto’ || p.stitch === ‘satin’;
d.width = p.width_mm || 3; d.runLen = p.run_len_mm || 2.5; d.stitchLen = p.stitch_len_mm || 3.5;
d.pull = p.pull_comp_mm === undefined ? +$(‘pullComp’).value || 0.25 : p.pull_comp_mm;
d.hand = p.hand || 0;

tests/test_app.py

@@ -1,9 +1,11 @@
“””End-to-end tests over the HTTP API (run with: pytest).”””
import io
import os
+import re
import sys
import tempfile

+import numpy as np
import pytest
from PIL import Image, ImageDraw

@@ -12,6 +14,7 @@ os.environ.setdefault(‘STITCHFORGE_DATA’,
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from fastapi.testclient import TestClient
+import pystitch
from app import app

client = TestClient(app)
@@ -51,6 +54,38 @@ def test_digitize_report(image_job):
assert d[‘threads’][0][‘thread_name’]

+def test_image_quantizer_supports_32_colors():
+ from digitizer import segment
+ colors = np.array([[i * 7 % 256, i * 13 % 256, i * 23 % 256, 255]
+ for i in range(32)], dtype=np.int16)
+ rgba = np.repeat(colors[:, None, :], 20, axis=1)
+ layers = segment.quantize(rgba, 32, np.ones(rgba.shape[:2], dtype=bool))
+ assert len(layers) == 32
+
+
+def test_image_color_limit_is_validated():
+ r = client.post(‘/api/analyze’,
+ files={‘image’: (‘t.png’, _test_png(), ‘image/png’)},
+ data={‘colors’: 33})
+ assert r.status_code == 400
+ assert r.json()[‘detail’] == ‘colors must be between 1 and 32’
+
+
+def test_vectorize_uses_requested_colors():
+ im = Image.new(‘RGBA’, (240, 80), (0, 0, 0, 0))
+ d = ImageDraw.Draw(im)
+ for i in range(12):
+ d.rectangle([i * 20 + 2, 10, i * 20 + 17, 69],
+ fill=(i * 19, i * 37 % 256, i * 61 % 256, 255))
+ buf = io.BytesIO()
+ im.save(buf, ‘PNG’)
+ r = client.post(‘/api/vectorize’,
+ files={‘image’: (‘colors.png’, buf.getvalue(), ‘image/png’)},
+ data={‘colors’: 12})
+ assert r.status_code == 200, r.text
+ assert len({layer[‘color’] for layer in r.json()[‘layers’]}) == 12
+
+
def test_exports(image_job):
job, _ = image_job
for fmt in (‘dst’, ‘pes’, ‘jef’, ‘exp’, ‘vp3’, ‘csv’):
@@ -429,6 +464,44 @@ def test_production_worksheet():
assert ps[‘max_stitch_mm’] > 0 and ps[‘thread_ft’] > ps[‘bobbin_ft’] > 0

+def test_worksheet_thread_lists_paginate(tmp_path, monkeypatch):
+ from inkstitchlib import worksheet
+ pattern = pystitch.EmbPattern()
+ layers = []
+ for index in range(40):
+ color = ((index * 47) % 256 << 16) | ((index * 83) % 256 << 8) | ((index * 131) % 256)
+ thread = pystitch.EmbThread()
+ thread.color = color
+ pattern.add_thread(thread)
+ if index:
+ pattern.color_change()
+ pattern.stitch_abs(index * 10, 0)
+ pattern.stitch_abs(index * 10 + 5, 5)
+ layers.append({‘name’: ‘Colour %d’ % (index + 1), ‘hex’: ‘#%06X’ % color})
+
+ drawn = []
+ original_text = worksheet._text
+
+ def capture_text(canvas, x, y, text, *args, **kwargs):
+ drawn.append(str(text))
+ return original_text(canvas, x, y, text, *args, **kwargs)
+
+ monkeypatch.setattr(worksheet, ‘_text’, capture_text)
+ report = {‘stitches’: 80, ‘width_mm’: 40, ‘height_mm’: 1,
+ ‘colour_changes’: 39, ‘travels’: 0, ‘trimmed’: 0}
+
+ production = tmp_path / ‘production.pdf’
+ worksheet.build(str(production), pattern, report, layers, layout=’production’)
+ assert ’40.’ in drawn
+ assert len(re.findall(rb’/Type\s*/Page\b’, production.read_bytes())) == 2
+
+ drawn.clear()
+ classic = tmp_path / ‘classic.pdf’
+ worksheet.build(str(classic), pattern, report, layers, layout=’classic’)
+ assert any(text.startswith(‘#40 ‘) for text in drawn)
+ assert len(re.findall(rb’/Type\s*/Page\b’, classic.read_bytes())) >= 3
+
+
def test_client_design_library():
# main page + studio routes
assert b’clients’ in client.get(‘/’).content.lower()