A6 quer: rollenschonender Druck (spaltenweise Zeichenreihenfolge)

Checkbox „Rollenschonend drucken" im Druck-Popup (nur bei A6). Setzt am
Job das Feld spaltenweise. Der Agent lädt das Schriftstück-SVG dann über
den Download-Endpunkt mit ?reorder=column: die Glyph-<path>-Elemente
werden nach x (Spalte) und y (oben→unten) permutiert – Signatur-<g>,
Hintergrund und Trennstrich bleiben unangetastet. Sichtbares Ergebnis
identisch, aber der rollengeführte Plotter fährt monoton in Vorschub-
richtung statt je Zeile die Rolle zurückzuspulen. Nur Schriftstücke,
nicht Kuverts.

Neues Job-Feld jobs.spaltenweise (Bootstrap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-24 09:09:37 +02:00
parent 65b826dc83
commit 08e12180b6
3 changed files with 2304 additions and 2246 deletions

View File

@@ -459,6 +459,7 @@ const collections = [
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }), f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle. // Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }), f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
f('spaltenweise', 'boolean', { interface: 'boolean', default: false, note: 'Rollenschonende Zeichenreihenfolge (spaltenweise, für A6 quer). Der Agent lädt das SVG dann umsortiert.' }),
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }), f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} }, { field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },

View File

@@ -1094,6 +1094,39 @@ const handler = (router, { services, getSchema, logger }) => {
return res.json({ files, exists: true }); return res.json({ files, exists: true });
}); });
// Zeichenreihenfolge „spaltenweise": permutiert NUR die Glyph-<path>-Elemente
// nach x (Spalte, aufsteigend), innerhalb einer Spalte nach y (oben→unten).
// Andere Elemente (Hintergrund-Rect, Signatur-<g>, Trennstrich bleibt ein Pfad)
// behalten ihre Position. Das sichtbare Ergebnis ist identisch – nur die
// Druckreihenfolge ändert sich, sodass der rollengeführte Plotter monoton in
// Vorschubrichtung fährt statt je Zeile die Rolle zurückzuspulen (A6 quer).
function svgSpaltenweise(svg) {
// <g>…</g>-Blöcke (z. B. eingebettete Signaturen) als Ganzes schützen – deren
// innere Pfade dürfen NICHT umsortiert werden (sie hängen an einem eigenen
// Gruppen-Transform). Signaturen stehen hinter dem Text, daher greedy bis zum
// letzten </g>. Ohne <g> bleibt alles unverändert.
const bloecke = [];
const safe = svg.replace(/<g\b[\s\S]*<\/g>/, (m) => { bloecke.push(m); return 'G0'; });
const RE = /<path\b[^>]*\/>/g;
const paths = safe.match(RE);
if (!paths || paths.length < 2) return svg;
const key = (p) => {
const m = p.match(/matrix\(\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
if (m) return [parseFloat(m[1]), parseFloat(m[2])];
const d = p.match(/\bd\s*=\s*"\s*M\s*([-\d.]+)[\s,]+([-\d.]+)/i);
if (d) return [parseFloat(d[1]), parseFloat(d[2])];
return [0, 0];
};
const sorted = paths
.map((p) => ({ p, k: key(p) }))
.sort((a, b) => (a.k[0] - b.k[0]) || (a.k[1] - b.k[1]))
.map((x) => x.p);
let i = 0;
let out = safe.replace(RE, () => sorted[i++]);
out = out.replace(/G0/, () => bloecke[0]);
return out;
}
router.get('/files/:order/download', async (req, res) => { router.get('/files/:order/download', async (req, res) => {
if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' }); if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
const nummer = safeOrder(req.params.order); const nummer = safeOrder(req.params.order);
@@ -1110,6 +1143,18 @@ const handler = (router, { services, getSchema, logger }) => {
if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) { if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) {
return res.status(404).json({ error: 'Datei nicht gefunden.' }); return res.status(404).json({ error: 'Datei nicht gefunden.' });
} }
// Optional: SVG spaltenweise umsortieren (rollenschonend, A6 quer).
if (ziel.endsWith('.svg') && String(req.query.reorder || '') === 'column') {
try {
const umsortiert = svgSpaltenweise(fs.readFileSync(ziel, 'utf8'));
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`);
return res.send(umsortiert);
} catch (e) {
logger.warn(`[skrift-orders] spaltenweise fehlgeschlagen (${rel}): ${e.message}`);
// Fallback: unverändert streamen.
}
}
const typ = ziel.endsWith('.svg') ? 'image/svg+xml' const typ = ziel.endsWith('.svg') ? 'image/svg+xml'
: ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream'; : ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream';
res.setHeader('Content-Type', typ); res.setHeader('Content-Type', typ);
@@ -1207,6 +1252,8 @@ const handler = (router, { services, getSchema, logger }) => {
order: order.id, machine: body.machine, type: typ, template: sec.template, order: order.id, machine: body.machine, type: typ, template: sec.template,
format: fmt.key, format_label: fmt.label, format: fmt.key, format_label: fmt.label,
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null, params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
// Rollenschonende Zeichenreihenfolge (A6 quer) – nur fürs Schriftstück sinnvoll.
spaltenweise: !!(sec.spaltenweise || body.spaltenweise),
status: 'queued', priority: prio, status: 'queued', priority: prio,
})); }));
} }

View File

@@ -194,7 +194,7 @@ const Board = defineComponent({
const musterListe = ref([]); const musterListe = ref([]);
// ── Modale ── // ── Modale ──
const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, msg: '', err: '', deNums: '', intNums: '', deN: 0, intN: 0 }); const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, spaltenweise: false, msg: '', err: '', deNums: '', intNums: '', deN: 0, intN: 0 });
const manuell = reactive({ open: false, req: null, form: 'sie', vorlageId: '', roh: '', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', const manuell = reactive({ open: false, req: null, form: 'sie', vorlageId: '', roh: '', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '',
// Adresse automatisch aufs Kuvert (Standard an) + eigenes Kuvert-Template/-Format. // Adresse automatisch aufs Kuvert (Standard an) + eigenes Kuvert-Template/-Format.
kuvert: true, envFormat: 'dinlang', envTemplate: null, msg: '', err: '' }); kuvert: true, envFormat: 'dinlang', envTemplate: null, msg: '', err: '' });
@@ -756,6 +756,8 @@ const Board = defineComponent({
druck.order = order; druck.order = order;
druck.machine = aktiveMaschinen.value[0]?.id ?? null; druck.machine = aktiveMaschinen.value[0]?.id ?? null;
druck.mode = 'queue'; druck.msg = ''; druck.err = ''; druck.mode = 'queue'; druck.msg = ''; druck.err = '';
// Rollenschonende Zeichenreihenfolge nur bei A6 anbieten (Default aus).
druck.spaltenweise = false;
druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) }; druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) };
druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null; druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null;
druck.deNums = ''; druck.intNums = ''; druck.deN = 0; druck.intN = 0; druck.deNums = ''; druck.intNums = ''; druck.deN = 0; druck.intN = 0;
@@ -789,7 +791,7 @@ const Board = defineComponent({
const sections = {}; const sections = {};
const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '', const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '',
params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } }); params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } });
if (druck.brief?.enabled) sections.brief = bau(druck.brief); if (druck.brief?.enabled) sections.brief = { ...bau(druck.brief), spaltenweise: !!druck.spaltenweise };
if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag); if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag);
if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; } if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; }
druck.err = ''; druck.msg = 'Wird eingereiht …'; druck.err = ''; druck.msg = 'Wird eingereiht …';
@@ -1478,6 +1480,14 @@ const Board = defineComponent({
]), ]),
druckSektion('brief', 'Briefe', '📄', 'brief'), druckSektion('brief', 'Briefe', '📄', 'brief'),
druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'), druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'),
// Rollenschonende Zeichenreihenfolge – nur bei A6 sinnvoll (Karte quer).
String(druck.order.format?.key || '').startsWith('a6') ? h('div', { style: SECTION + 'margin-bottom:12px;' }, [
h('label', { style: 'display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: druck.spaltenweise, onChange: (e) => { druck.spaltenweise = e.target.checked; }, style: 'width:18px;height:18px;' }),
h('span', { style: 'font-weight:600;' }, 'Rollenschonend drucken (spaltenweise)'),
]),
subdued('Für A6 quer: Zeichen werden spaltenweise (kurze Seite, oben→unten) gedruckt, damit die Rolle nicht je Zeile zurückfährt. Das sichtbare Ergebnis auf der Karte bleibt gleich – nur die Schriftstücke (nicht die Kuverts).', 'margin-top:6px;'),
]) : null,
h('div', { style: 'margin:12px 0 4px;' }, [ h('div', { style: 'margin:12px 0 4px;' }, [
segToggle(busy ? 'queue' : druck.mode, [ segToggle(busy ? 'queue' : druck.mode, [
{ key: 'direct', label: '⚡ Direktdruck', disabled: busy }, { key: 'direct', label: '⚡ Direktdruck', disabled: busy },