From 572bebcb285e57c10699004adddd71dd781a9274 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 09:52:27 +0100 Subject: [PATCH 01/13] perf(snippets): trim vector snippet sources to the cut box (#770) A vector snippet no longer carries a full copy of its source page. The new js/pdf/vector-cull.js trims the page content to what the cut box can reach: paths, text objects, images and Form XObjects that are provably outside the box are dropped, while graphics state, clipping paths, marked content, shadings and BX/EX sections are kept. Anything that cannot be read with certainty leaves the page untouched. The trimmed mini-PDF is only used when it is at most half the size of the full one. Mini-PDFs no longer carry the source page's annotations, and fonts and images shared by trimmed snippets of the same page are merged into one copy on save. Refs #509 Review: included as is; no changes were needed on top. --- .../js/annotations/vector-snippet-store.js | 4 +- .../js/pdf/saver/vector-snippet.js | 8 +- open-pdf-studio/js/pdf/vector-cull.js | 834 ++++++++++++++++++ open-pdf-studio/js/pdf/vector-cull.test.mjs | 397 +++++++++ open-pdf-studio/js/pdf/vector-embed.js | 71 +- .../js/tools/tools/vector-snippet-tool.js | 6 +- open-pdf-studio/scripts/unit-testlijst.txt | 1 + 7 files changed, 1306 insertions(+), 15 deletions(-) create mode 100644 open-pdf-studio/js/pdf/vector-cull.js create mode 100644 open-pdf-studio/js/pdf/vector-cull.test.mjs diff --git a/open-pdf-studio/js/annotations/vector-snippet-store.js b/open-pdf-studio/js/annotations/vector-snippet-store.js index 406946542..73cb45be1 100644 --- a/open-pdf-studio/js/annotations/vector-snippet-store.js +++ b/open-pdf-studio/js/annotations/vector-snippet-store.js @@ -3,7 +3,9 @@ // Een knipsel draagt zijn eigen kopie van de bronpagina, zodat het blijft // werken als de bron gesloten, verplaatst of gewijzigd is. Tien knipsels uit // hetzelfde blad hoeven die kopie niet tien keer te bewaren: de annotatie -// draagt alleen een sleutel, de bytes staan hier. +// draagt alleen een sleutel, de bytes staan hier. (Een knipsel uit een zware +// tekening krijgt een teruggesnoeide kopie met een eigen sleutel, #509 — zie +// pdf/vector-cull.js.) // // De pdfium-worker rendert vanaf een PAD, niet vanuit bytes (zie // pdfium-worker/src/main.rs). Wie een voorvertoning wil vraagt daarom padVan() diff --git a/open-pdf-studio/js/pdf/saver/vector-snippet.js b/open-pdf-studio/js/pdf/saver/vector-snippet.js index 31812ce61..6c36a21a8 100644 --- a/open-pdf-studio/js/pdf/saver/vector-snippet.js +++ b/open-pdf-studio/js/pdf/saver/vector-snippet.js @@ -10,7 +10,8 @@ // werken (scherper hertekenen bij inzoomen, of later alsnog vastzetten). import { PDFName, PDFRef, PDFDict, PDFArray, PDFStream } from 'pdf-lib'; -import { bedKnipselIn } from '../vector-embed.js'; +import { bedKnipselIn, knipselBronWortels } from '../vector-embed.js'; +import { ontdubbel } from '../vector-cull.js'; import { flattenKnipselSleutels } from '../flatten-bytes.js'; /** Sleutel van het woordenboek met bronpagina's op de catalogus. */ @@ -233,6 +234,11 @@ function _bereikbaar(context, starts) { export function ruimKnipselRestenOp(doc, oudeStempels = [], { nieuwVanaf } = {}) { const context = doc.context; + // Gesnoeide knipsels van één bronpagina brachten elk hun eigen kopie van + // dezelfde fonts en afbeeldingen mee (#509): samenvoegen. + const wortels = knipselBronWortels(doc); + if (wortels.length > 1) ontdubbel(context, wortels); + const gebruikt = new Set(); for (const pagina of doc.getPages()) { const annots = pagina.node.lookup(PDFName.of('Annots')); diff --git a/open-pdf-studio/js/pdf/vector-cull.js b/open-pdf-studio/js/pdf/vector-cull.js new file mode 100644 index 000000000..6fc5aea82 --- /dev/null +++ b/open-pdf-studio/js/pdf/vector-cull.js @@ -0,0 +1,834 @@ +// Vectorknipsel — de bronpagina terugsnoeien tot wat het knipvak raakt (#509). +// +// Een knipsel droeg tot nu toe de héle bronpagina mee: de mini-PDF voor de +// voorvertoning, de bron op de catalogus en het Form XObject in de appearance +// bevatten allemaal de volledige inhoudsstroom, met alleen een /BBox die het +// zichtbare deel begrenst. Bij een zware tekening is dat megabytes per knipsel, +// en de pdfium-worker verwerkt die hele stroom voor elke tegel van elk +// zoomniveau. +// +// Hier gaat alles uit de inhoudsstroom wat AANTOONBAAR buiten het vak valt: +// - paden (m/l/c/v/y/re … S/f/B/n) waarvan de omhullende — inclusief +// lijndikte en verstek — het vak niet raakt; +// - tekstobjecten (BT … ET) waarvan een ruime bovengrens van alle glyfen +// het vak niet raakt; +// - afbeeldingen en Form XObjects (Do, BI … EI) waarvan het kader buiten +// het vak ligt. +// Alles wat de grafische toestand verandert blijft staan (q/Q, cm, gs, +// kleuren, lijnstijl, tekst-state, uitsnijpaden, marked content), zodat wat +// wél getekend wordt precies dezelfde toestand ziet. Een tekstobject dat +// wegvalt laat zijn tekst-state achter (Tf, Tc, TL …): die geldt ook na ET. +// Daarna vallen lege q … Q-blokken en lege marked-content-paren weg, en de +// resources waar niemand meer naar verwijst. +// +// Bij twijfel blijft het staan: een onbekend font (Type3, verticaal schrift), +// tekst die uitsnijdt (Tr 4–7), shadings (sh) en alles binnen BX … EX. Kan de +// stroom niet zonder twijfel gelezen worden, dan blijft de pagina zoals hij +// was — snoeien is een optimalisatie, nooit een reden voor een ander beeld. +// +// Zuiver (alleen pdf-lib), zodat het onder `node --test` draait. + +import { + PDFArray, PDFDict, PDFName, PDFNumber, PDFRawStream, PDFRef, PDFStream, decodePDFRawStream, +} from 'pdf-lib'; +import { tokenizeContentStream, matMul } from '../text/content-stream-text.js'; + +/** Vaste speling (pt) rond alles wat getekend wordt: anti-aliasing en + * haarlijnen die een renderer minstens één beeldpunt breed maakt. */ +const SPELING = 4; +/** Glyfbreedte (em) als een font er zelf geen opgeeft (standaard-14). */ +const STANDAARD_BREEDTE = 1.5; +/** Minimale uitsteek van een glyf rond zijn oorsprong (em). */ +const MIN_GLYF_UITSTEEK = 2; + +const IDENTITEIT = [1, 0, 0, 1, 0, 0]; + +const PAD_BOUW = new Set(['m', 'l', 'c', 'v', 'y', 'h', 're']); +const PAD_VERF = new Set(['S', 's', 'f', 'F', 'f*', 'B', 'B*', 'b', 'b*', 'n']); +const PAD_STREEK = new Set(['S', 's', 'B', 'B*', 'b', 'b*']); +const TEKST_STAAT = new Set(['Tc', 'Tw', 'Tz', 'TL', 'Tf', 'Tr', 'Ts']); +const ALGEMENE_STAAT = new Set([ + 'w', 'J', 'j', 'M', 'd', 'ri', 'i', 'gs', + 'CS', 'cs', 'SC', 'SCN', 'sc', 'scn', 'G', 'g', 'RG', 'rg', 'K', 'k', +]); +const TEKST_POSITIE = new Set(['Td', 'TD', 'Tm', 'T*']); +const TEKST_TONEN = new Set(['Tj', 'TJ', "'", '"']); + +/** Grootste rek van een matrix (bovengrens van de grootste singuliere waarde). */ +function rek(m) { + return Math.hypot(m[0], m[1], m[2], m[3]); +} + +function punt(m, x, y) { + return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; +} + +/** Omhullende van een rechthoek (lokale ruimte) onder matrix `m`. */ +function kaderOnder(m, x0, y0, x1, y1) { + const p = [punt(m, x0, y0), punt(m, x1, y0), punt(m, x0, y1), punt(m, x1, y1)]; + return { + minX: Math.min(p[0][0], p[1][0], p[2][0], p[3][0]), + minY: Math.min(p[0][1], p[1][1], p[2][1], p[3][1]), + maxX: Math.max(p[0][0], p[1][0], p[2][0], p[3][0]), + maxY: Math.max(p[0][1], p[1][1], p[2][1], p[3][1]), + }; +} + +/** Ligt omhullende `o`, met `marge` eromheen, helemaal buiten het vak? */ +function buiten(o, vak, marge) { + const r = marge + SPELING; + if (![o.minX, o.minY, o.maxX, o.maxY, r].every(Number.isFinite)) return false; + return o.maxX + r < vak.left || o.minX - r > vak.right + || o.maxY + r < vak.bottom || o.minY - r > vak.top; +} + +function getal(n) { + const t = Number(n).toFixed(4).replace(/\.?0+$/, ''); + return t === '-0' || t === '' ? '0' : t; +} + +class Onleesbaar extends Error {} + +/** + * Snoeit een inhoudsstroom tot wat het vak kan raken. + * + * @param {string} src de inhoudsstroom als latin1-string (1 teken = 1 byte) + * @param {{left:number,bottom:number,right:number,top:number}} vak in de + * gebruikersruimte van de stroom + * @param {object} [bronnen] opzoekfuncties voor resources, elk mag null geven + * (= onbekend, dus behouden): + * - font(naam) → { breedte, uitsteek } in em, of null + * - xobject(naam) → { soort: 'image' } | { soort: 'form', bbox, matrix } | null + * - extGState(naam) → { LW?, ML?, font? } | null + * @returns {{inhoud:string, namen:Set, weg:number, totaal:number}|null} + * null als de stroom niet zeker te lezen is; laat de pagina dan staan. + */ +export function snoeiInhoud(src, vak, bronnen = {}) { + try { + return _snoei(src, vak, bronnen); + } catch (err) { + if (err instanceof Onleesbaar) return null; + throw err; + } +} + +function _snoei(src, vak, bronnen) { + const fontVan = bronnen.font || (() => null); + const xobjectVan = bronnen.xobject || (() => null); + const gsVan = bronnen.extGState || (() => null); + + // Eenheden van de uitvoer: { s, e } (stuk bron) of { tekst }, met een soort: + // 'q' | 'Q' | 'mc' | 'emc' | 'staat' (verandert alleen toestand) | 'verf'. + const eenheden = []; + let weg = 0; + let totaal = 0; + + let st = { + ctm: IDENTITEIT, lw: 1, ml: 10, + font: null, tfs: 0, tc: 0, tw: 0, th: 1, tl: 0, rise: 0, tr: 0, + }; + const stapel = []; + let compat = 0; + let pad = null; // { s, minX, minY, maxX, maxY, punten, clip, namen } + let cx = 0, cy = 0; // huidig punt (lokale ruimte), voor v + let tekst = null; // lopend tekstobject + const reserves = []; // weggevallen transparante beelden, zie onderaan + let transparantGetekend = false; + + const namenVan = (operanden) => operanden.filter((t) => t.t === 'name').map((t) => t.v); + const getallen = (operanden) => operanden.filter((t) => t.t === 'num').map((t) => t.v); + + const voegPuntToe = (x, y) => { + const [px, py] = punt(st.ctm, x, y); + if (px < pad.minX) pad.minX = px; + if (px > pad.maxX) pad.maxX = px; + if (py < pad.minY) pad.minY = py; + if (py > pad.maxY) pad.maxY = py; + pad.punten++; + }; + const streekMarge = () => (st.lw > 0 ? st.lw : 0) / 2 * rek(st.ctm) * Math.max(st.ml, 1.5); + + const voegToe = (eenheid) => { + totaal++; + eenheden.push(eenheid); + }; + const laatVallen = () => { totaal++; weg++; }; + + const verwerkStaat = (op, ops, nums) => { + switch (op) { + case 'q': stapel.push(st); st = { ...st }; return; + case 'Q': if (stapel.length) st = stapel.pop(); return; + case 'cm': + if (nums.length !== 6) throw new Onleesbaar('cm'); + st.ctm = matMul(nums, st.ctm); + return; + case 'w': if (nums.length) st.lw = Math.abs(nums[nums.length - 1]); return; + case 'M': if (nums.length) st.ml = Math.abs(nums[nums.length - 1]); return; + case 'gs': { + const naam = namenVan(ops).pop(); + const info = naam != null ? gsVan(naam) : null; + if (!info) { st.lw = Infinity; st.font = null; return; } + if (Number.isFinite(info.LW)) st.lw = Math.abs(info.LW); + if (Number.isFinite(info.ML)) st.ml = Math.abs(info.ML); + if (info.font) st.font = null; // font via ExtGState: niet gevolgd, dus onbekend + return; + } + case 'Tf': { + const naam = namenVan(ops).pop(); + st.font = naam != null ? fontVan(naam) : null; + if (nums.length) st.tfs = Math.abs(nums[nums.length - 1]); + return; + } + case 'Tc': if (nums.length) st.tc = nums[nums.length - 1]; return; + case 'Tw': if (nums.length) st.tw = nums[nums.length - 1]; return; + case 'Tz': if (nums.length) st.th = nums[nums.length - 1] / 100; return; + case 'TL': if (nums.length) st.tl = nums[nums.length - 1]; return; + case 'Ts': if (nums.length) st.rise = nums[nums.length - 1]; return; + case 'Tr': if (nums.length) st.tr = nums[nums.length - 1]; return; + default: + } + }; + + // ── tekstobjecten ── + const beginTekst = (s) => { + tekst = { s, lm: IDENTITEIT, r: 0, weg: true, mc: 0, rest: [], namen: [] }; + }; + const toonGebied = (strings, aanpassing) => { + // Bovengrens van alles wat deze tekenreeks kan raken, in de ruimte van de + // regelmatrix: de pen staat op afstand ≤ r van de regeloorsprong, schuift + // per glyf hooguit (breedte·Tfs + |Tc| + |Tw|)·|Th| op, en een glyf steekt + // hooguit `uitsteek` em rond zijn oorsprong uit. + const f = st.font; + if (!f || st.tr >= 4 || !Number.isFinite(st.lw)) { tekst.weg = false; return; } + const th = Math.abs(st.th); + const glyfen = strings.reduce((n, s) => n + s.length, 0); + const opschuiving = (glyfen * (f.breedte * st.tfs + Math.abs(st.tc) + Math.abs(st.tw)) + + aanpassing / 1000 * st.tfs) * th; + const m = st.tfs * f.uitsteek; + const hx = tekst.r + opschuiving + m * Math.max(th, 1); + const hy = Math.abs(st.rise) + m; + tekst.r += opschuiving; + const marge = (st.tr === 1 || st.tr === 2) ? streekMarge() : 0; + const o = kaderOnder(matMul(tekst.lm, st.ctm), -hx, -hy, hx, hy); + if (!buiten(o, vak, marge)) tekst.weg = false; + }; + const volgendeRegel = () => { + tekst.lm = matMul([1, 0, 0, 1, 0, -st.tl], tekst.lm); + tekst.r = 0; + }; + + const verwerkInTekst = (op, ops, nums, s, e) => { + tekst.namen.push(...namenVan(ops)); + const ruw = () => src.slice(s, e); + if (TEKST_STAAT.has(op) || ALGEMENE_STAAT.has(op)) { + verwerkStaat(op, ops, nums); + tekst.rest.push(ruw()); + return; + } + switch (op) { + case 'Td': case 'TD': + if (nums.length !== 2) throw new Onleesbaar(op); + if (op === 'TD') { st.tl = -nums[1]; tekst.rest.push(`${getal(-nums[1])} TL`); } + tekst.lm = matMul([1, 0, 0, 1, nums[0], nums[1]], tekst.lm); + tekst.r = 0; + return; + case 'Tm': + if (nums.length !== 6) throw new Onleesbaar('Tm'); + tekst.lm = nums; + tekst.r = 0; + return; + case 'T*': volgendeRegel(); return; + case 'Tj': case "'": + if (op === "'") volgendeRegel(); + toonGebied(ops.filter((t) => t.t === 'str').map((t) => t.v), 0); + return; + case '"': { + if (nums.length !== 2) throw new Onleesbaar('"'); + st.tw = nums[0]; + st.tc = nums[1]; + tekst.rest.push(`${getal(nums[0])} Tw ${getal(nums[1])} Tc`); + volgendeRegel(); + toonGebied(ops.filter((t) => t.t === 'str').map((t) => t.v), 0); + return; + } + case 'TJ': { + const strs = ops.filter((t) => t.t === 'str').map((t) => t.v); + const aanp = ops.filter((t) => t.t === 'num').reduce((n, t) => n + Math.abs(t.v), 0); + toonGebied(strs, aanp); + return; + } + case 'BMC': case 'BDC': tekst.mc++; return; + case 'EMC': if (--tekst.mc < 0) tekst.weg = false; return; + case 'MP': case 'DP': return; + default: + // Wat niet in een tekstobject hoort (paden, Do, q/Q, cm): het object + // blijft dan heel staan, maar de toestand volgen we wel. + tekst.weg = false; + if (op === 'q' || op === 'Q' || op === 'cm') verwerkStaat(op, ops, nums); + else if (op === 'BX') compat++; + else if (op === 'EX') compat = Math.max(0, compat - 1); + } + }; + const eindTekst = (e) => { + const t = tekst; + tekst = null; + if (t.weg && t.mc === 0) { + laatVallen(); + // Wat het object aan toestand zette, geldt ook na ET: dat blijft staan. + if (t.rest.length) { + const namen = []; + for (const r of t.rest) { + for (const tok of tokenizeContentStream(r)) if (tok.t === 'name') namen.push(tok.v); + } + eenheden.push({ tekst: t.rest.join('\n'), soort: 'staat', namen }); + } + return; + } + voegToe({ s: t.s, e, soort: 'verf', namen: t.namen }); + }; + + // ── de lus ── + const tokens = tokenizeContentStream(src); + let ops = []; + let opStart = -1; + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (tok.t !== 'op') { + if (!ops.length) opStart = tok.s; + ops.push(tok); + continue; + } + const op = tok.v; + const s = ops.length ? opStart : tok.s; + const e = tok.e; + const operanden = ops; + ops = []; + const nums = getallen(operanden); + + if (tekst) { + if (op === 'ET') eindTekst(e); + else verwerkInTekst(op, operanden, nums, s, e); + continue; + } + + if (pad && !PAD_BOUW.has(op) && !PAD_VERF.has(op) && op !== 'W' && op !== 'W*') { + throw new Onleesbaar(`pad onderbroken door ${op}`); + } + + if (PAD_BOUW.has(op)) { + if (!pad) pad = { s, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, punten: 0, clip: false }; + const n = nums; + const verwacht = { m: 2, l: 2, c: 6, v: 4, y: 4, h: 0, re: 4 }[op]; + if (n.length !== verwacht) throw new Onleesbaar(op); + if (op === 're') { + voegPuntToe(n[0], n[1]); voegPuntToe(n[0] + n[2], n[1]); + voegPuntToe(n[0], n[1] + n[3]); voegPuntToe(n[0] + n[2], n[1] + n[3]); + cx = n[0]; cy = n[1]; + } else if (op !== 'h') { + // De omhullende van de controlepunten omvat de hele Bézierkromme. + if (op === 'v') voegPuntToe(cx, cy); + for (let k = 0; k < n.length; k += 2) voegPuntToe(n[k], n[k + 1]); + cx = n[n.length - 2]; cy = n[n.length - 1]; + } + continue; + } + if (op === 'W' || op === 'W*') { + if (!pad) pad = { s, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, punten: 0, clip: false }; + pad.clip = true; + continue; + } + if (PAD_VERF.has(op)) { + const p = pad || { s, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, punten: 0, clip: false }; + pad = null; + // Een uitsnijpad blijft altijd staan; met `n` verandert het alleen de + // toestand (Q zet het terug), met een verfoperator tekent het ook. + if (p.clip) { voegToe({ s: p.s, e, soort: op === 'n' ? 'staat' : 'verf', namen: [] }); continue; } + if (op === 'n') { laatVallen(); continue; } // pad zonder verf en zonder uitsnede + const marge = PAD_STREEK.has(op) ? streekMarge() : 0; + if (p.punten > 0 && buiten(p, vak, marge)) { laatVallen(); continue; } + voegToe({ s: p.s, e, soort: 'verf', namen: [] }); + continue; + } + + switch (op) { + case 'BT': + beginTekst(s); + continue; + case 'ET': + throw new Onleesbaar('ET zonder BT'); + case 'q': + verwerkStaat(op, operanden, nums); + voegToe({ s, e, soort: 'q', namen: [] }); + continue; + case 'Q': + verwerkStaat(op, operanden, nums); + voegToe({ s, e, soort: 'Q', namen: [] }); + continue; + case 'BMC': case 'BDC': + voegToe({ s, e, soort: 'mc', namen: namenVan(operanden) }); + continue; + case 'EMC': + voegToe({ s, e, soort: 'emc', namen: [] }); + continue; + case 'MP': case 'DP': + voegToe({ s, e, soort: 'staat', namen: namenVan(operanden) }); + continue; + case 'Do': { + const naam = namenVan(operanden).pop(); + const info = naam != null ? xobjectVan(naam) : null; + let o = null; + if (info?.soort === 'image') o = kaderOnder(st.ctm, 0, 0, 1, 1); + else if (info?.soort === 'form' && info.bbox?.length === 4) { + const [x0, y0, x1, y1] = info.bbox; + o = kaderOnder(matMul(info.matrix || IDENTITEIT, st.ctm), x0, y0, x1, y1); + } + if (o && buiten(o, vak, 0)) { + if (info.transparant) { + // Zie `reserves` hieronder: misschien blijft deze toch staan. + reserves.push({ s, e, soort: 'verf', namen: [naam], grootte: info.grootte || 0 }); + eenheden.push(reserves[reserves.length - 1]); + } + laatVallen(); + continue; + } + if (info?.transparant) transparantGetekend = true; + voegToe({ s, e, soort: 'verf', namen: naam != null ? [naam] : [] }); + continue; + } + case 'BI..EI': { + const kop = src.slice(s, e); + const idx = kop.search(/\sID[\s\S]/); + const namen = [...(idx > 0 ? kop.slice(0, idx) : '').matchAll(/\/([^\s/<>[\]()%{}]+)/g)].map((m) => m[1]); + if (buiten(kaderOnder(st.ctm, 0, 0, 1, 1), vak, 0)) { laatVallen(); continue; } + voegToe({ s, e, soort: 'verf', namen }); + continue; + } + case 'BX': compat++; voegToe({ s, e, soort: 'verf', namen: [] }); continue; + case 'EX': compat = Math.max(0, compat - 1); voegToe({ s, e, soort: 'verf', namen: [] }); continue; + default: + } + if (TEKST_STAAT.has(op) || ALGEMENE_STAAT.has(op) || op === 'cm') { + verwerkStaat(op, operanden, nums); + voegToe({ s, e, soort: 'staat', namen: namenVan(operanden) }); + continue; + } + if (op === 'sh' || op === 'd0' || op === 'd1') { + voegToe({ s, e, soort: 'verf', namen: namenVan(operanden) }); + continue; + } + if (compat > 0) { voegToe({ s, e, soort: 'verf', namen: namenVan(operanden) }); continue; } + if (TEKST_POSITIE.has(op) || TEKST_TONEN.has(op)) throw new Onleesbaar(`${op} buiten BT`); + throw new Onleesbaar(`onbekende operator ${op}`); + } + if (pad || tekst) throw new Onleesbaar('stroom eindigt midden in een pad of tekstobject'); + + // Een afbeelding met SMask (of een Form met transparantiegroep) maakt dat + // renderers de hele pagina met transparantie mengen: PDFium zodra er zo'n + // beeld getekend wordt, MuPDF zodra er een in de resources staat. Valt die + // keuze na het snoeien anders uit, dan schuift de anti-aliasing van alles + // een paar tinten. Daarom blijft, als er zo'n beeld was maar er binnen het + // vak geen overblijft, het kleinste ervan staan — buiten het vak, dus + // onzichtbaar in het knipsel. + if (reserves.length) { + const bewaar = transparantGetekend ? null + : reserves.reduce((a, b) => (b.grootte < a.grootte ? b : a)); + for (const r of reserves) r.weg = r !== bewaar; + if (bewaar) weg--; + } + const weggevallen = verwijderLegeBlokken(eenheden.filter((u) => !u.weg)); + const stukken = []; + const namen = new Set(); + const over = eenheden.filter((u) => !u.weg); + for (let i = 0; i < over.length; i++) { + if (weggevallen[i]) continue; + const u = over[i]; + stukken.push(u.tekst ?? src.slice(u.s, u.e)); + for (const n of u.namen) namen.add(n); + } + return { inhoud: stukken.join('\n'), namen, weg, totaal }; +} + +/** + * q … Q zonder iets dat tekent (alleen toestand, die Q terugzet) en + * BMC/BDC … EMC zonder iets erin vallen weg. Klopt de nesting niet, dan + * blijft alles staan. + * @returns {boolean[]} per eenheid: valt weg + */ +function verwijderLegeBlokken(eenheden) { + const weg = new Array(eenheden.length).fill(false); + const lagen = [{ soort: 'top', start: -1, verf: false, iets: false }]; + for (let i = 0; i < eenheden.length; i++) { + const soort = eenheden[i].soort; + const top = lagen[lagen.length - 1]; + if (soort === 'q' || soort === 'mc') { + lagen.push({ soort, start: i, verf: false, iets: false }); + continue; + } + if (soort === 'Q' || soort === 'emc') { + const verwacht = soort === 'Q' ? 'q' : 'mc'; + if (top.soort === 'top' && soort === 'Q') { top.verf = top.iets = true; continue; } // Q te veel + if (top.soort !== verwacht) return new Array(eenheden.length).fill(false); + lagen.pop(); + const ouder = lagen[lagen.length - 1]; + const leeg = soort === 'Q' ? !top.verf : !top.iets; + if (leeg) { for (let k = top.start; k <= i; k++) weg[k] = true; continue; } + ouder.verf ||= top.verf; + ouder.iets = true; + continue; + } + top.iets = true; + if (soort === 'verf') top.verf = true; + } + return weg; +} + +// ── pdf-lib-kant ────────────────────────────────────────────────────────── + +const BRON_SOORTEN = ['Font', 'XObject', 'ExtGState', 'Pattern', 'Shading', 'Properties', 'ColorSpace']; + +function latin1(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + } + return s; +} + +function naarBytes(s) { + const b = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0xff; + return b; +} + +function streamBytes(stream) { + if (stream instanceof PDFRawStream) return decodePDFRawStream(stream).decode(); + if (typeof stream?.getUnencodedContents === 'function') return stream.getUnencodedContents(); + if (typeof stream?.getContents === 'function') return stream.getContents(); + throw new Onleesbaar('onleesbare stroom'); +} + +/** De inhoud van een pagina als één latin1-string, of null. */ +function paginaInhoud(context, node) { + const c = node.Contents(); + if (!c) return ''; + const delen = c instanceof PDFArray ? c.asArray().map((r) => context.lookup(r)) : [c]; + const stukken = []; + for (const d of delen) { + if (!(d instanceof PDFStream)) return null; + try { stukken.push(latin1(streamBytes(d))); } catch { return null; } + } + // Tokens lopen niet over stroomgrenzen heen; een witregel ertussen is genoeg. + return stukken.join('\n'); +} + +function getalVan(context, v) { + const o = v instanceof PDFRef ? context.lookup(v) : v; + return o instanceof PDFNumber ? o.asNumber() : null; +} + +function getallenVan(context, v) { + const o = v instanceof PDFRef ? context.lookup(v) : v; + if (!(o instanceof PDFArray)) return null; + const uit = []; + for (const x of o.asArray()) { + const n = getalVan(context, x); + if (n == null) return null; + uit.push(n); + } + return uit; +} + +function dictVan(context, v) { + const o = v instanceof PDFRef ? context.lookup(v) : v; + if (o instanceof PDFDict) return o; + if (o instanceof PDFStream) return o.dict; + return null; +} + +function naamVan(context, v) { + const o = v instanceof PDFRef ? context.lookup(v) : v; + return o instanceof PDFName ? o.asString().replace(/^\//, '') : null; +} + +/** Grootste glyfbreedte en uitsteek (em) van een font, of null als onbekend. */ +export function fontMaten(context, fontDict) { + const d = dictVan(context, fontDict); + if (!d) return null; + const soort = naamVan(context, d.get(PDFName.of('Subtype'))); + if (!soort || soort === 'Type3') return null; + let breedtes = []; + let beschrijving = null; + if (soort === 'Type0') { + const codering = d.get(PDFName.of('Encoding')); + const cnaam = naamVan(context, codering); + if (cnaam ? /-V$/.test(cnaam) : getalVan(context, dictVan(context, codering)?.get(PDFName.of('WMode'))) === 1) { + return null; // verticaal schrift: de pen loopt omlaag, niet gevolgd + } + if (!cnaam && !dictVan(context, codering)) return null; + const afstammelingen = d.get(PDFName.of('DescendantFonts')); + const lijst = afstammelingen instanceof PDFRef ? context.lookup(afstammelingen) : afstammelingen; + const cid = lijst instanceof PDFArray ? dictVan(context, lijst.get(0)) : null; + if (!cid) return null; + const dw = getalVan(context, cid.get(PDFName.of('DW'))); + breedtes.push(dw ?? 1000); + const w = cid.get(PDFName.of('W')); + const wa = w instanceof PDFRef ? context.lookup(w) : w; + if (wa instanceof PDFArray) { + for (const x of wa.asArray()) { + const o = x instanceof PDFRef ? context.lookup(x) : x; + if (o instanceof PDFArray) { + const g = getallenVan(context, o); + if (!g) return null; + breedtes.push(...g); + } else if (o instanceof PDFNumber) { + breedtes.push(o.asNumber()); // ook begin/eind-codes: alleen een ruimere grens + } else return null; + } + } else if (wa) return null; + beschrijving = dictVan(context, cid.get(PDFName.of('FontDescriptor'))); + } else { + const w = getallenVan(context, d.get(PDFName.of('Widths'))); + beschrijving = dictVan(context, d.get(PDFName.of('FontDescriptor'))); + if (w) breedtes = w; + const ontbrekend = getalVan(context, beschrijving?.get(PDFName.of('MissingWidth'))); + if (ontbrekend != null) breedtes.push(ontbrekend); + if (!w) breedtes.push(STANDAARD_BREEDTE * 1000); + } + const breedte = Math.max(0, ...breedtes.map(Math.abs)) / 1000; + const bbox = getallenVan(context, beschrijving?.get(PDFName.of('FontBBox'))); + const uitsteek = Math.max(MIN_GLYF_UITSTEEK, breedte, ...(bbox?.length === 4 ? bbox.map((n) => Math.abs(n) / 1000) : [])); + if (!Number.isFinite(breedte) || !Number.isFinite(uitsteek)) return null; + return { breedte, uitsteek }; +} + +function bronOpzoekers(context, bronDict) { + const sub = (soort) => dictVan(context, bronDict?.get(PDFName.of(soort))); + const fonts = sub('Font'); + const xobjs = sub('XObject'); + const gss = sub('ExtGState'); + const cache = new Map(); + const eenmaal = (k, f) => { if (!cache.has(k)) cache.set(k, f()); return cache.get(k); }; + return { + font: (naam) => eenmaal(`F/${naam}`, () => { + const v = fonts?.get(PDFName.of(naam)); + return v ? fontMaten(context, v) : null; + }), + xobject: (naam) => eenmaal(`X/${naam}`, () => { + const v = xobjs?.get(PDFName.of(naam)); + const o = v instanceof PDFRef ? context.lookup(v) : v; + if (!(o instanceof PDFStream)) return null; + const soort = naamVan(context, o.dict.get(PDFName.of('Subtype'))); + const grootte = (st) => (st instanceof PDFStream ? (st.contents?.length ?? 0) : 0); + if (soort === 'Image') { + const smask = o.dict.get(PDFName.of('SMask')); + const smaskStroom = smask instanceof PDFRef ? context.lookup(smask) : smask; + const transparant = smaskStroom instanceof PDFStream + || (getalVan(context, o.dict.get(PDFName.of('SMaskInData'))) || 0) > 0; + return { soort: 'image', transparant, grootte: grootte(o) + grootte(smaskStroom) }; + } + if (soort !== 'Form') return null; + const bbox = getallenVan(context, o.dict.get(PDFName.of('BBox'))); + const matrix = o.dict.has(PDFName.of('Matrix')) ? getallenVan(context, o.dict.get(PDFName.of('Matrix'))) : IDENTITEIT; + if (bbox?.length !== 4 || matrix?.length !== 6) return null; + return { soort: 'form', bbox, matrix, transparant: o.dict.has(PDFName.of('Group')), grootte: grootte(o) }; + }), + extGState: (naam) => eenmaal(`G/${naam}`, () => { + const g = dictVan(context, gss?.get(PDFName.of(naam))); + if (!g) return null; + const info = {}; + if (g.has(PDFName.of('LW'))) { + info.LW = getalVan(context, g.get(PDFName.of('LW'))); + if (info.LW == null) return null; + } + if (g.has(PDFName.of('ML'))) { + info.ML = getalVan(context, g.get(PDFName.of('ML'))); + if (info.ML == null) return null; + } + if (g.has(PDFName.of('Font'))) info.font = true; + return info; + }), + }; +} + +/** + * Een ExtGState met transparantie blijft staan, ook als niets er meer naar + * verwijst: MuPDF kijkt naar de resources om te kiezen of hij de pagina als + * transparantiegroep mengt (zie ook de `reserves` in _snoei). Zo'n + * woordenboek kost een paar bytes. + */ +function mengt(context, soort, v) { + if (soort !== 'ExtGState') return false; + const d = dictVan(context, v); + if (!d) return false; + const bm = d.get(PDFName.of('BM')); + const bmNaam = naamVan(context, bm); + if (bm && bmNaam !== 'Normal' && bmNaam !== 'Compatible') return true; + const sm = d.get(PDFName.of('SMask')); + if (sm && naamVan(context, sm) !== 'None') return true; + for (const k of ['ca', 'CA']) { + const n = getalVan(context, d.get(PDFName.of(k))); + if (n != null && n < 1) return true; + } + return false; +} + +/** Gebruikt dit object de resources van de pagina omdat het er zelf geen heeft? */ +function leentPaginaBronnen(context, v) { + const d = dictVan(context, v); + if (!d || d.has(PDFName.of('Resources'))) return false; + const soort = naamVan(context, d.get(PDFName.of('Subtype'))); + const patroon = getalVan(context, d.get(PDFName.of('PatternType'))); + return soort === 'Form' || soort === 'Type3' || patroon === 1; +} + +/** + * Snoeit een pdf-lib-pagina in place tot wat `vak` (ongedraaide + * gebruikersruimte) kan raken: inhoudsstroom én resources. + * @returns {{voor:number, na:number, weg:number, totaal:number}|null} null als + * er niets veilig te snoeien viel; de pagina is dan onaangeroerd. + */ +export function snoeiPagina(doc, page, vak) { + const context = doc.context; + const node = page.node; + const src = paginaInhoud(context, node); + if (src == null || src === '') return null; + const bronDict = node.Resources(); + const r = snoeiInhoud(src, vak, bronOpzoekers(context, bronDict)); + if (!r || r.weg === 0) return null; + + node.set(PDFName.of('Contents'), context.register(context.flateStream(naarBytes(r.inhoud)))); + + if (bronDict instanceof PDFDict) { + const nieuw = context.obj({}); + let lenen = false; + const soorten = new Map(); + for (const [sleutel, waarde] of bronDict.entries()) { + const soort = sleutel.asString().replace(/^\//, ''); + const sub = BRON_SOORTEN.includes(soort) ? dictVan(context, waarde) : null; + if (!sub) { nieuw.set(sleutel, waarde); continue; } + const gehouden = context.obj({}); + for (const [naam, v] of sub.entries()) { + // decodeText: de tokenizer leest #xx in namen al als het teken zelf. + if (!r.namen.has(naam.decodeText()) && !mengt(context, soort, v)) continue; + gehouden.set(naam, v); + if (leentPaginaBronnen(context, v)) lenen = true; + } + soorten.set(sleutel, gehouden); + } + // Een Form, tiling-patroon of Type3-font zonder eigen resources leest die + // van de pagina: welke namen daarin gebruikt worden zien we hier niet. + if (!lenen) { + for (const [sleutel, gehouden] of soorten) nieuw.set(sleutel, gehouden); + node.set(PDFName.of('Resources'), nieuw); + } + } + return { voor: src.length, na: r.inhoud.length, weg: r.weg, totaal: r.totaal }; +} + +// ── ontdubbelen ─────────────────────────────────────────────────────────── + +function fnv(bytes) { + let h1 = 0x811c9dc5 >>> 0; + let h2 = 0x01000193 >>> 0; + for (let i = 0; i < bytes.length; i++) { + h1 = Math.imul(h1 ^ bytes[i], 0x01000193) >>> 0; + h2 = Math.imul(h2 + bytes[i] + i, 0x85ebca6b) >>> 0; + } + return `${h1.toString(16)}${h2.toString(16)}:${bytes.length}`; +} + +const ruweBytes = (s) => (s instanceof PDFRawStream ? s.contents : s.getContents()); + +function gelijk(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** + * Voegt identieke indirecte objecten samen die vanaf `wortels` bereikbaar + * zijn. Bedoeld voor de resources van de knipsels in één document: elk + * gesnoeid knipsel is een eigen mini-PDF, dus pdf-lib kopieert fonts en + * afbeeldingen van dezelfde bronpagina per knipsel opnieuw. Alleen + * aanroepen op objecten die net gekopieerd zijn en waar verder niemand naar + * verwijst — dan maakt het samenvoegen niets anders dan het bestand kleiner. + * + * @param {import('pdf-lib').PDFContext} context + * @param {Array} wortels bv. de /Resources van de knipsel-Forms + * @returns {number} aantal weggehaalde dubbele objecten + */ +export function ontdubbel(context, wortels) { + // Alle indirecte objecten onder de wortels (de wortels zelf niet). + const refs = new Map(); + const stapel = [...wortels]; + const directen = []; + while (stapel.length) { + const o = stapel.pop(); + if (o instanceof PDFRef) { + if (refs.has(o.tag)) continue; + const doel = context.lookup(o); + if (!doel) continue; + refs.set(o.tag, o); + stapel.push(doel); + continue; + } + if (o instanceof PDFDict) { directen.push(o); for (const [, v] of o.entries()) stapel.push(v); } + else if (o instanceof PDFArray) { directen.push(o); for (const v of o.asArray()) stapel.push(v); } + else if (o instanceof PDFStream) { directen.push(o.dict); for (const [, v] of o.dict.entries()) stapel.push(v); } + } + + // Canonieke vorm per object; verwijzingen worden de vorm van wat ze aanwijzen. + // Objecten in een kring krijgen hun eigen tag erbij en worden nooit samengevoegd. + const vorm = new Map(); + const bezig = new Set(); + const vormVan = (o) => { + if (o instanceof PDFRef) { + if (vorm.has(o.tag)) return vorm.get(o.tag); + if (bezig.has(o.tag)) return `@${o.tag}`; + bezig.add(o.tag); + const doel = context.lookup(o); + let v = doel ? vormVan(doel) : 'null'; + if (v.includes('@')) v = `${v}#${o.tag}`; + bezig.delete(o.tag); + vorm.set(o.tag, v); + return v; + } + if (o instanceof PDFDict) { + const delen = o.entries().map(([k, v]) => `${k.asString()} ${vormVan(v)}`).sort(); + return `<<${delen.join(' ')}>>`; + } + if (o instanceof PDFArray) return `[${o.asArray().map(vormVan).join(' ')}]`; + if (o instanceof PDFStream) return `S${vormVan(o.dict)}${fnv(ruweBytes(o))}`; + return String(o); + }; + + const eerste = new Map(); // vorm → canonieke ref + const vervang = new Map(); // tag → canonieke ref + for (const ref of refs.values()) { + const v = vormVan(ref); + if (v.includes('@')) continue; + const kandidaat = eerste.get(v); + if (!kandidaat) { eerste.set(v, ref); continue; } + const a = context.lookup(kandidaat); + const b = context.lookup(ref); + if (a instanceof PDFStream && !gelijk(ruweBytes(a), ruweBytes(b))) continue; + vervang.set(ref.tag, kandidaat); + } + if (!vervang.size) return 0; + + const herschrijf = (c) => { + if (c instanceof PDFDict) { + for (const [k, v] of c.entries()) if (v instanceof PDFRef && vervang.has(v.tag)) c.set(k, vervang.get(v.tag)); + } else if (c instanceof PDFArray) { + for (let i = 0; i < c.size(); i++) { + const v = c.get(i); + if (v instanceof PDFRef && vervang.has(v.tag)) c.set(i, vervang.get(v.tag)); + } + } + }; + for (const c of directen) herschrijf(c); + for (const w of wortels) if (!(w instanceof PDFRef)) herschrijf(w); + let weg = 0; + for (const tag of vervang.keys()) if (context.delete(refs.get(tag))) weg++; + return weg; +} diff --git a/open-pdf-studio/js/pdf/vector-cull.test.mjs b/open-pdf-studio/js/pdf/vector-cull.test.mjs new file mode 100644 index 000000000..14659dd82 --- /dev/null +++ b/open-pdf-studio/js/pdf/vector-cull.test.mjs @@ -0,0 +1,397 @@ +// Vectorknipsel snoeien (#509): de bronpagina van een knipsel houdt alleen wat +// het knipvak kan raken. +// +// Twee soorten bewijs. De zuivere tests leggen per operatorsoort vast wat weg +// mag en wat moet blijven. De rendertests rasteren het vak vóór en na snoeien +// met MuPDF en eisen identieke beeldpunten — op een gegenereerde zware pagina +// en op een echte tekening uit de testmap. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { PDFDocument, PDFName, PDFString, StandardFonts } from 'pdf-lib'; +import * as mupdf from 'mupdf'; + +import { snoeiInhoud, snoeiPagina, fontMaten, ontdubbel } from './vector-cull.js'; +import { knipselAlsMiniPdf, SNOEI_DREMPEL } from './vector-embed.js'; +import { bouwKnipselAppearance, ruimKnipselRestenOp } from './saver/vector-snippet.js'; +import { sleutelVoor } from '../annotations/vector-snippet-store.js'; + +const VAK = { left: 100, bottom: 100, right: 200, top: 200 }; +const FONT = { breedte: 1, uitsteek: 2 }; +const bronnen = { + font: (n) => (n === 'F1' ? FONT : null), + xobject: (n) => (n === 'Im1' ? { soort: 'image' } : n === 'Fm1' ? { soort: 'form', bbox: [0, 0, 10, 10], matrix: [1, 0, 0, 1, 0, 0] } : null), + extGState: (n) => (n === 'G1' ? {} : n === 'Dik' ? { LW: 400 } : null), +}; +const snoei = (src, b = bronnen) => snoeiInhoud(src, VAK, b); +const regels = (r) => r.inhoud.split('\n'); + +// --- paden ------------------------------------------------------------------ + +test('een pad buiten het vak valt weg, een pad erin blijft', () => { + const r = snoei('0 0 m 50 50 l S\n120 120 m 150 150 l S\n500 500 10 10 re f'); + assert.deepEqual(regels(r), ['120 120 m 150 150 l S']); + assert.equal(r.weg, 2); +}); + +test('een pad dat het vak alleen met zijn lijndikte raakt, blijft', () => { + // De lijn ligt 30 pt links van het vak; met lijndikte 80 reikt hij erin. + const r = snoei('80 w 70 120 m 70 180 l S\n1 w 50 120 m 50 180 l S'); + assert.deepEqual(regels(r), ['80 w', '70 120 m 70 180 l S', '1 w']); +}); + +test('ook een lijndikte uit een ExtGState telt mee', () => { + const r = snoei('/Dik gs 0 150 m 10 150 l S'); + assert.ok(r === null || r.inhoud.includes('0 150 m 10 150 l S')); +}); + +test('een Bézierkromme telt met haar controlepunten', () => { + // Begin- en eindpunt liggen buiten het vak, de boog loopt erdoorheen. + const r = snoei('50 150 m 150 400 150 -100 250 150 c S'); + assert.ok(r === null || r.inhoud.includes(' c S')); +}); + +test('de CTM wordt gevolgd, ook door q/Q heen', () => { + const r = snoei('q 1 0 0 1 100 100 cm 10 10 m 20 20 l S Q\n10 10 m 20 20 l S'); + assert.deepEqual(regels(r), ['q', '1 0 0 1 100 100 cm', '10 10 m 20 20 l S', 'Q']); +}); + +test('uitsnijpaden blijven altijd staan, ook buiten het vak', () => { + const r = snoei('0 0 10 10 re W n\n120 120 m 150 150 l S'); + assert.deepEqual(regels(r), ['0 0 10 10 re W n', '120 120 m 150 150 l S']); +}); + +test('een uitsnijpad dat ook vult, telt als tekenen', () => { + const r = snoei('q 0 0 10 10 re W f Q'); + assert.deepEqual(regels(r), ['q', '0 0 10 10 re W f', 'Q']); +}); + +test('een pad zonder verf en zonder uitsnede valt weg', () => { + assert.equal(snoei('120 120 m 150 150 l n\n120 120 m 150 150 l S').inhoud, '120 120 m 150 150 l S'); +}); + +// --- toestand en lege blokken ----------------------------------------------- + +test('toestand blijft staan; lege q … Q-blokken vallen weg', () => { + const r = snoei('1 0 0 RG 2 w\nq 0 0 1 rg 0 0 m 5 5 l S Q\nq q 0 g Q Q\n120 120 m 150 150 l S'); + assert.deepEqual(regels(r), ['1 0 0 RG', '2 w', '120 120 m 150 150 l S']); +}); + +test('marked content: een leeg paar valt weg, een gevuld paar blijft', () => { + const r = snoei('/OC /L1 BDC 0 0 m 5 5 l S EMC\n/OC /L2 BDC 120 120 m 150 150 l S EMC'); + assert.deepEqual(regels(r), ['/OC /L2 BDC', '120 120 m 150 150 l S', 'EMC']); + assert.ok(r.namen.has('L2')); + assert.ok(!r.namen.has('L1')); +}); + +test('marked content met alleen toestand erin blijft staan', () => { + const r = snoei('/Span BMC 1 0 0 rg EMC\n120 120 m 150 150 l f'); + assert.deepEqual(regels(r), ['/Span BMC', '1 0 0 rg', 'EMC', '120 120 m 150 150 l f']); +}); + +// --- tekst ------------------------------------------------------------------ + +test('een tekstobject buiten het vak valt weg, zijn tekst-state blijft', () => { + const r = snoei('BT /F1 12 Tf 0 0 Td 14 TL 2 Tc (Hallo) Tj ET\nBT 0 -14 TD (x) Tj ET\nBT 120 150 Td (binnen) Tj ET'); + assert.deepEqual(regels(r), ['/F1 12 Tf', '14 TL', '2 Tc', '14 TL', 'BT 120 150 Td (binnen) Tj ET']); +}); + +test('" zet Tw en Tc ook als het object wegvalt', () => { + const r = snoei('BT /F1 10 Tf 0 0 Td 3 1 (a) " ET\nBT 120 150 Td (b) Tj ET'); + assert.deepEqual(regels(r), ['/F1 10 Tf', '3 Tw 1 Tc', 'BT 120 150 Td (b) Tj ET']); +}); + +test('een lange tekenreeks die het vak in loopt, blijft', () => { + // Begint op x=0, maar 20 glyfen van 10 pt lopen tot voorbij x=100. + const r = snoei('BT /F1 10 Tf 0 150 Td (abcdefghijklmnopqrst) Tj ET'); + assert.ok(r === null || r.inhoud.includes('Tj')); +}); + +test('tekst blijft staan bij een onbekend font, uitsnijdende tekst of onbekende gs', () => { + for (const src of [ + 'BT /F9 12 Tf 0 0 Td (a) Tj ET', + 'BT /F1 12 Tf 7 Tr 0 0 Td (a) Tj ET', + '/Onbekend gs BT /F1 12 Tf 0 0 Td (a) Tj ET', + ]) { + const r = snoei(src); + assert.ok(r === null || r.inhoud.includes('(a) Tj'), src); + } +}); + +test('een tekstobject met een tekenoperator erin blijft heel', () => { + const r = snoei('BT /F1 12 Tf 0 0 Td (a) Tj 0 0 m 1 1 l S ET'); + assert.ok(r === null || r.inhoud.includes('(a) Tj')); +}); + +// --- afbeeldingen en Forms -------------------------------------------------- + +test('Do valt weg als het kader van de afbeelding of Form buiten het vak ligt', () => { + const r = snoei('q 10 0 0 10 0 0 cm /Im1 Do Q\nq 10 0 0 10 150 150 cm /Im1 Do Q\nq 1 0 0 1 0 0 cm /Fm1 Do Q\n/Onbekend Do'); + assert.deepEqual(regels(r), ['q', '10 0 0 10 150 150 cm', '/Im1 Do', 'Q', '/Onbekend Do']); + assert.ok(r.namen.has('Im1')); + assert.ok(!r.namen.has('Fm1')); +}); + +test('een inline afbeelding buiten het vak valt weg, erin blijft hij met zijn namen', () => { + const beeld = (x) => `q 10 0 0 10 ${x} ${x} cm BI /W 1 /H 1 /CS /CSx /BPC 8 ID \xff EI Q`; + const r = snoei(`${beeld(0)}\n${beeld(150)}`); + assert.equal(r.weg, 1); + assert.ok(r.inhoud.includes('150 150 cm')); + assert.ok(r.namen.has('CSx')); +}); + +test('shadings en BX … EX blijven altijd staan', () => { + const r = snoei('/Sh0 sh\nBX 1 vreemd EX\n0 0 m 5 5 l S'); + assert.ok(r.inhoud.includes('/Sh0 sh')); + assert.ok(r.inhoud.includes('vreemd')); +}); + +// --- niet zeker te lezen: niets doen ----------------------------------------- + +test('een onbekende operator of een kapot pad laat de stroom staan', () => { + assert.equal(snoei('0 0 m 5 5 l S\nfoo'), null); + assert.equal(snoei('0 0 m 5 5 l q S'), null); + assert.equal(snoei('BT (a) Tj'), null); + assert.equal(snoei('(a) Tj'), null); +}); + +// --- fonts ------------------------------------------------------------------ + +test('fontMaten leest breedtes en FontBBox; Type3 is onbekend', async () => { + const d = await PDFDocument.create(); + const helv = d.context.lookup((await d.embedFont(StandardFonts.Helvetica)).ref); + assert.ok(helv === undefined || helv); // embedFont schrijft pas bij flush + await d.flush(); + const fonts = [...d.context.enumerateIndirectObjects()].filter(([, o]) => o?.get?.(PDFName.of('Type'))?.toString() === '/Font'); + const m = fontMaten(d.context, fonts[0][1]); + assert.ok(m.breedte > 0 && m.breedte <= 2); + assert.ok(m.uitsteek >= 2); + const t3 = d.context.obj({ Type: 'Font', Subtype: 'Type3' }); + assert.equal(fontMaten(d.context, t3), null); + const verticaal = d.context.obj({ Type: 'Font', Subtype: 'Type0', Encoding: 'Identity-V' }); + assert.equal(fontMaten(d.context, verticaal), null); +}); + +// --- een zware pagina, gerenderd --------------------------------------------- + +const BREED = 1200; +const HOOG = 800; +const KNIP = { left: 400, bottom: 250, right: 700, top: 500 }; + +/** Een pagina vol lijnen, krommen, tekst, beelden en marked content. */ +async function zwareBron({ rotatie = 0 } = {}) { + const d = await PDFDocument.create(); + const p = d.addPage([BREED, HOOG]); + const font = await d.embedFont(StandardFonts.Helvetica); + const fontNaam = p.node.newFontDictionary('F1', font.ref); + const pixels = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + const beeld = d.context.register(d.context.stream(pixels, { + Type: 'XObject', Subtype: 'Image', Width: 2, Height: 2, ColorSpace: 'DeviceRGB', BitsPerComponent: 8, + })); + const beeldNaam = p.node.newXObject('Im', beeld); + const gs = p.node.newExtGState('GS', d.context.register(d.context.obj({ Type: 'ExtGState', LW: 3 }))); + + let x = 12345; + const rnd = () => ((x = (x * 1103515245 + 12345) % 2147483648) / 2147483648); + const n = (v) => v.toFixed(2); + const ops = ['q 0.5 w 0 0 1 RG']; + for (let i = 0; i < 3000; i++) { + const x0 = rnd() * BREED, y0 = rnd() * HOOG; + const soort = i % 6; + if (soort === 0) ops.push(`${n(x0)} ${n(y0)} m ${n(x0 + rnd() * 60 - 30)} ${n(y0 + rnd() * 60 - 30)} l S`); + else if (soort === 1) ops.push(`${n(rnd())} ${n(rnd())} 0 rg ${n(x0)} ${n(y0)} ${n(rnd() * 25)} ${n(rnd() * 25)} re f`); + else if (soort === 2) ops.push(`${n(x0)} ${n(y0)} m ${n(x0 + 40)} ${n(y0 + 60)} ${n(x0 + 80)} ${n(y0 - 60)} ${n(x0 + 120)} ${n(y0)} c S`); + else if (soort === 3) ops.push(`q ${n(1 + rnd() * 8)} w ${n(x0)} ${n(y0)} m ${n(x0 + 50)} ${n(y0)} l S Q`); + else if (soort === 4) { + const a = rnd() * Math.PI * 2; + ops.push(`BT /${fontNaam} ${n(6 + rnd() * 18)} Tf ${n(Math.cos(a))} ${n(Math.sin(a))} ${n(-Math.sin(a))} ${n(Math.cos(a))} ${n(x0)} ${n(y0)} Tm (Tekst ${i}) Tj ET`); + } else ops.push(`/OC /L${i % 3} BDC q ${n(10 + rnd() * 20)} 0 0 ${n(10 + rnd() * 20)} ${n(x0)} ${n(y0)} cm /${beeldNaam} Do Q EMC`); + } + // Randgevallen rond het vak: een dikke lijn die er net in reikt, een + // uitsnede eromheen, een inline afbeelding erin en een erbuiten. + ops.push(`/${gs} gs 6 w ${KNIP.left - 4} 300 m ${KNIP.left - 4} 400 l S`); + ops.push(`q ${KNIP.left + 20} ${KNIP.bottom + 20} 100 100 re W n 1 0 0 rg 0 0 ${BREED} ${HOOG} re f Q`); + ops.push(`q 30 0 0 30 ${KNIP.left + 150} ${KNIP.bottom + 30} cm BI /W 2 /H 1 /CS /RGB /BPC 8 ID \xff\x00\x00\x00\x00\xff EI Q`); + ops.push('q 30 0 0 30 20 20 cm BI /W 2 /H 1 /CS /RGB /BPC 8 ID \xff\x00\x00\x00\x00\xff EI Q'); + ops.push('Q'); + const tekst = ops.join('\n'); + const bytes = Uint8Array.from(tekst, (c) => c.charCodeAt(0)); + p.node.set(PDFName.of('Contents'), d.context.register(d.context.flateStream(bytes))); + const props = d.context.obj({}); + for (let i = 0; i < 3; i++) props.set(PDFName.of(`L${i}`), d.context.register(d.context.obj({ Type: 'OCG', Name: PDFString.of(`L${i}`) }))); + p.node.Resources().set(PDFName.of('Properties'), props); + if (rotatie) p.node.set(PDFName.of('Rotate'), d.context.obj(rotatie)); + return d.save(); +} + +/** Rastert `vak` (ongedraaide gebruikersruimte) van pagina 0 met MuPDF. + * `mb` is de MediaBox: MuPDF legt haar linkerbovenhoek op de oorsprong. */ +function rasterVak(bytes, vak, zoom, mb = { x: 0, y: 0, height: HOOG }) { + const page = mupdf.Document.openDocument(bytes, 'application/pdf').loadPage(0); + // Zonder /Rotate rekent MuPDF in dezelfde ruimte als het vak. + const m = mupdf.Matrix.concat( + mupdf.Matrix.translate(-(vak.left - mb.x), -(mb.y + mb.height - vak.top)), + mupdf.Matrix.scale(zoom, zoom), + ); + const b = Math.ceil((vak.right - vak.left) * zoom); + const h = Math.ceil((vak.top - vak.bottom) * zoom); + const pix = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, b, h], false); + pix.clear(255); + const dev = new mupdf.DrawDevice(mupdf.Matrix.identity, pix); + page.runPageContents(dev, m); + dev.close(); + return pix.getPixels().slice(); // kopie: het wasm-geheugen kan verschuiven +} + +async function zonderRotatie(bytes) { + const d = await PDFDocument.load(bytes); + d.getPage(0).node.delete(PDFName.of('Rotate')); + return d.save(); +} + +function verschillen(a, b) { + assert.equal(a.length, b.length); + let n = 0; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; + return n; +} + +test('zware pagina: gesnoeid knipsel is veel kleiner en rendert identiek', async () => { + const bron = await zwareBron(); + const vol = await knipselAlsMiniPdf(bron, 0); + const gesnoeid = await knipselAlsMiniPdf(bron, 0, 0, null, KNIP); + assert.ok(gesnoeid.length < vol.length * 0.3, `${gesnoeid.length} vs ${vol.length}`); + for (const zoom of [0.5, 1.5, 4]) { + assert.equal(verschillen(rasterVak(vol, KNIP, zoom), rasterVak(gesnoeid, KNIP, zoom)), 0, `zoom ${zoom}`); + } + // Wat niet in het vak gebruikt wordt, gaat ook uit de resources. + const pagina = (await PDFDocument.load(gesnoeid)).getPage(0); + assert.ok(pagina.node.Resources().lookup(PDFName.of('Font'))); +}); + +test('zware pagina met /Rotate: het vak blijft in ongedraaide paginaruimte', async () => { + const bron = await zwareBron({ rotatie: 90 }); + const vol = await knipselAlsMiniPdf(bron, 0); + const gesnoeid = await knipselAlsMiniPdf(bron, 0, 90, null, KNIP); + assert.equal((await PDFDocument.load(gesnoeid)).getPage(0).getRotation().angle, 180); + const a = rasterVak(await zonderRotatie(vol), KNIP, 1.5); + const b = rasterVak(await zonderRotatie(gesnoeid), KNIP, 1.5); + assert.equal(verschillen(a, b), 0); +}); + +test('een knipsel dat weinig scheelt, deelt de volle mini-PDF', async () => { + const d = await PDFDocument.create(); + const p = d.addPage([600, 400]); + p.drawRectangle({ x: 100, y: 80, width: 120, height: 60 }); + p.drawRectangle({ x: 400, y: 300, width: 20, height: 20 }); + const bron = await d.save(); + const vol = await knipselAlsMiniPdf(bron, 0); + const vak = { left: 90, bottom: 70, right: 230, top: 150 }; + // Er valt een rechthoek weg, maar de mini-PDF wordt niet de helft kleiner. + assert.ok(SNOEI_DREMPEL < 1); + assert.equal(await knipselAlsMiniPdf(bron, 0, 0, null, vak), vol); +}); + +test('de mini-PDF draagt geen annotaties (en dus geen tweede kopie van de pagina) mee', async () => { + const d = await PDFDocument.create(); + const p = d.addPage([600, 400]); + p.drawRectangle({ x: 100, y: 80, width: 120, height: 60 }); + const annot = d.context.register(d.context.obj({ Type: 'Annot', Subtype: 'Square', Rect: [0, 0, 10, 10], P: p.ref })); + p.node.addAnnot(annot); + const mini = await PDFDocument.load(await knipselAlsMiniPdf(await d.save(), 0)); + assert.equal(mini.getPage(0).node.get(PDFName.of('Annots')), undefined); + const paginas = [...mini.context.enumerateIndirectObjects()].filter(([, o]) => o?.get?.(PDFName.of('Type'))?.toString() === '/Page'); + assert.equal(paginas.length, 1); +}); + +test('snoeiPagina houdt resources met #-codes in hun naam', async () => { + const d = await PDFDocument.create(); + const p = d.addPage([600, 400]); + const font = await d.embedFont(StandardFonts.Helvetica); + p.node.Resources().set(PDFName.of('Font'), d.context.obj({ 'F 1': font.ref })); + const inhoud = 'BT /F#201 12 Tf 120 150 Td (a) Tj ET\n0 0 m 5 5 l S'; + p.node.set(PDFName.of('Contents'), d.context.register(d.context.stream(inhoud))); + assert.ok(snoeiPagina(d, p, VAK)); + const fonts = p.node.Resources().lookup(PDFName.of('Font')); + assert.deepEqual(fonts.keys().map((k) => k.decodeText()), ['F 1']); +}); + +test('snoeiPagina laat een pagina die het vak helemaal vult ongemoeid', async () => { + const d = await PDFDocument.create(); + const p = d.addPage([600, 400]); + p.drawRectangle({ x: 100, y: 80, width: 120, height: 60 }); + const voor = p.node.get(PDFName.of('Contents')); + assert.equal(snoeiPagina(d, p, { left: 0, bottom: 0, right: 600, top: 400 }), null); + assert.equal(p.node.get(PDFName.of('Contents')), voor); +}); + +// --- opslaan: fonts en beelden één keer -------------------------------------- + +test('gesnoeide knipsels van één pagina delen bij opslaan hun fonts en beelden', async () => { + const bron = await zwareBron(); + const vakken = [KNIP, { left: 50, bottom: 50, right: 300, top: 250 }]; + const doel = await PDFDocument.create(); + const pagina = doel.addPage([1000, 600]); + for (const [i, vak] of vakken.entries()) { + const mini = await knipselAlsMiniPdf(bron, 0, 0, null, vak); + const sleutel = sleutelVoor(mini); + const r = await bouwKnipselAppearance(doel, { bronBytes: mini, srcBox: vak, rect: [i * 400, 0, i * 400 + 300, 250], sleutel }); + pagina.drawPage(r.ingebed, { x: i * 400, y: 0, width: r.breedte, height: r.hoogte }); + pagina.node.addAnnot(doel.context.register(doel.context.obj({ + Type: 'Annot', Subtype: 'Stamp', Rect: [0, 0, 1, 1], OPS_SnippetKey: PDFString.of(sleutel), + }))); + } + await doel.flush(); + ruimKnipselRestenOp(doel, [], { nieuwVanaf: 0 }); + const heropend = await PDFDocument.load(await doel.save()); + const tel = (soort, waarde) => [...heropend.context.enumerateIndirectObjects()] + .filter(([, o]) => (o?.dict ?? o)?.get?.(PDFName.of(soort))?.toString() === waarde).length; + // De catalogus bewaart de twee mini-PDF's als ondoorzichtige bytes; in de + // appearances zelf staat het font en het beeld nog maar één keer. + assert.equal(tel('Type', '/Font'), 1); + assert.equal(tel('Subtype', '/Image'), 1); +}); + +test('ontdubbel voegt alleen echt gelijke objecten samen', async () => { + const d = await PDFDocument.create(); + const s = (b) => d.context.register(d.context.stream(new Uint8Array(b), { Subtype: 'Image' })); + const a = s([1, 2, 3]); + const b = s([1, 2, 3]); + const c = s([1, 2, 4]); + const w1 = d.context.obj({ XObject: d.context.obj({ I: a, J: c }) }); + const w2 = d.context.obj({ XObject: d.context.obj({ I: b }) }); + assert.equal(ontdubbel(d.context, [w1, w2]), 1); + const i1 = w1.lookup(PDFName.of('XObject')).get(PDFName.of('I')); + const i2 = w2.lookup(PDFName.of('XObject')).get(PDFName.of('I')); + assert.equal(i1, i2); + assert.ok(d.context.lookup(i1)); + assert.equal(w1.lookup(PDFName.of('XObject')).get(PDFName.of('J')), c); + assert.equal([a, b].filter((r) => d.context.lookup(r)).length, 1); +}); + +// --- een echte tekening ----------------------------------------------------- + +const ECHT = new URL('../../../test pdf-bestanden/Originele bestanden/NKE2D2_opm_aw.pdf', import.meta.url); + +test('echte zware tekening: veel kleiner en beeldpunt voor beeldpunt gelijk', { skip: !existsSync(ECHT) }, async () => { + const bytes = new Uint8Array(readFileSync(ECHT)); + const bron = await PDFDocument.load(bytes); + const mb = bron.getPage(1).getMediaBox(); + const vak = { left: mb.x + mb.width * 0.5, bottom: mb.y + mb.height * 0.5, right: mb.x + mb.width * 0.75, top: mb.y + mb.height * 0.8 }; + const vol = await knipselAlsMiniPdf(bytes, 1, 0, bron); + const gesnoeid = await knipselAlsMiniPdf(bytes, 1, 0, bron, vak); + // Wat blijft is vooral het beeld dat de transparantiekeuze van de renderer + // vasthoudt; de tekening zelf krimpt veel harder. + assert.ok(gesnoeid.length < vol.length * 0.4, `${gesnoeid.length} vs ${vol.length}`); + const inhoud = async (b) => { + const d = await PDFDocument.load(b); + const c = d.getPage(0).node.Contents(); + const delen = c.asArray ? c.asArray().map((r) => d.context.lookup(r)) : [c]; + return delen.reduce((n, st) => n + st.contents.length, 0); + }; + assert.ok(await inhoud(gesnoeid) < await inhoud(vol) * 0.2); + const a = rasterVak(await zonderRotatie(vol), vak, 1, mb); + const b = rasterVak(await zonderRotatie(gesnoeid), vak, 1, mb); + assert.equal(verschillen(a, b), 0); +}); diff --git a/open-pdf-studio/js/pdf/vector-embed.js b/open-pdf-studio/js/pdf/vector-embed.js index 81d48b7fc..f1ba124c6 100644 --- a/open-pdf-studio/js/pdf/vector-embed.js +++ b/open-pdf-studio/js/pdf/vector-embed.js @@ -133,9 +133,15 @@ export function paginaRotatie(page) { /** * Trekt één pagina uit een PDF los tot een zelfstandige eenpagina-PDF. Dat is - * wat een knipsel bij zich draagt: de héle bronpagina, niet het bijgesneden - * vak — want dat is wat bedKnipselIn nodig heeft. copyPages neemt alleen de - * resources mee waar die pagina naar verwijst. + * wat een knipsel bij zich draagt. copyPages neemt alleen de resources mee + * waar die pagina naar verwijst. + * + * Met `vak` (het geknipte vak, ongedraaide gebruikersruimte) wordt de pagina + * teruggesnoeid tot wat dat vak kan raken (#509, zie vector-cull.js): de + * paginamaat en de coördinaten blijven gelijk, dus srcBox en weergaveVak + * kloppen nog, maar de voorvertoning en het opgeslagen knipsel dragen niet + * meer de hele tekening mee. Zonder `vak` gaat de hele pagina mee (een + * ingevoegde CAD-tekening die je in zijn geheel plaatst). * * `extraRotatie` is een draaiing die alleen in de app bestaat (nog niet * opgeslagen): die gaat mee in de /Rotate van de mini-PDF, zodat het knipsel @@ -143,21 +149,36 @@ export function paginaRotatie(page) { * @returns {Promise} */ const miniPdfs = new WeakMap(); +/** Een gesnoeide mini-PDF moet hooguit dit deel van de volle zijn. */ +export const SNOEI_DREMPEL = 0.5; -export async function knipselAlsMiniPdf(bronBytes, paginaIndex = 0, extraRotatie = 0, geladenBron = null) { +export async function knipselAlsMiniPdf(bronBytes, paginaIndex = 0, extraRotatie = 0, geladenBron = null, vak = null) { + const snoei = normaliseerVak(vak); + if (snoei) { + // Een gesnoeide mini-PDF hoort bij één vak: niet bewaren, de knipselstore + // ontdubbelt al op inhoud. Alleen gebruiken als hij hooguit de helft van + // de volle is: knipsels van dezelfde pagina delen de volle mini-PDF (één + // sleutel), gesnoeide niet. Gebruikt het vak vrijwel alle fonts en + // afbeeldingen van het blad, dan zou elk knipsel die opnieuw meedragen. + const [vol, gesnoeid] = await Promise.all([ + knipselAlsMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron), + maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron, snoei), + ]); + return gesnoeid.length <= vol.length * SNOEI_DREMPEL ? gesnoeid : vol; + } let bladen = miniPdfs.get(bronBytes); if (!bladen) miniPdfs.set(bronBytes, bladen = new Map()); const key = `${paginaIndex}|${_kwartslag(extraRotatie)}`; if (!bladen.has(key)) { - const taak = maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron); + const taak = maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron, null); bladen.set(key, taak); taak.catch(() => bladen.delete(key)); } return bladen.get(key); } -async function maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron) { - const { PDFDocument, degrees } = await import('pdf-lib'); +async function maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron, vak) { + const { PDFDocument, PDFName, degrees } = await import('pdf-lib'); const bron = geladenBron || await PDFDocument.load(bronBytes); const mini = await PDFDocument.create(); const [pagina] = await mini.copyPages(bron, [paginaIndex]); @@ -165,6 +186,15 @@ async function maakMiniPdf(bronBytes, paginaIndex, extraRotatie, geladenBron) { pagina.setRotation(degrees(_kwartslag(paginaRotatie(pagina) + _kwartslag(extraRotatie)))); } mini.addPage(pagina); + const { snoeiPagina } = await import('./vector-cull.js'); + const { ruimOnbereikbaarOp } = await import('./flatten-bytes.js'); + // Annotaties tekent geen van de afnemers: de worker rendert de mini-PDF + // zonder, en embedPage neemt alleen de inhoud. Hun /P wijst bovendien naar + // de oorspronkelijke pagina, zodat copyPages die met al haar inhoud nóg + // een keer meekopieerde (#509). + pagina.node.delete(PDFName.of('Annots')); + if (vak) snoeiPagina(mini, pagina, vak); + ruimOnbereikbaarOp(mini); return await mini.save(); } @@ -178,6 +208,27 @@ export function metInhoud(pagina) { return pagina; } +// Per doel-document: bronobjecten mogen nooit PDFRefs tussen saves delen. +const gedeeldeBronnen = new WeakMap(); +// Per doel-document: de zware bron-Forms, voor knipselBronWortels. +const bronForms = new WeakMap(); + +/** + * De woordenboeken van de zware bron-Forms die in `doelDoc` zijn ingebed. Na + * `await doelDoc.flush()` staan ze in de context; ruimKnipselRestenOp voegt + * via deze wortels de fonts en afbeeldingen samen die gesnoeide knipsels van + * dezelfde bronpagina elk opnieuw meebrachten (#509). + * @returns {object[]} PDFDicts + */ +export function knipselBronWortels(doelDoc) { + const uit = []; + for (const form of bronForms.get(doelDoc) || []) { + const stream = form.ref ? doelDoc.context.lookup(form.ref) : null; + if (stream?.dict) uit.push(stream.dict); + } + return uit; +} + /** * Bedt het gekozen vak van een bronpagina in als Form XObject in `doelDoc`. * @@ -191,9 +242,6 @@ export function metInhoud(pagina) { * @param {number} [paginaIndex] * @returns {Promise<{ingebed:object, breedte:number, hoogte:number, rotatie:number}>} */ -// Per doel-document: bronobjecten mogen nooit PDFRefs tussen saves delen. -const gedeeldeBronnen = new WeakMap(); - export async function bedKnipselIn(doelDoc, bronBytes, srcBox, paginaIndex = 0, { alsGroep = false } = {}) { const vak = normaliseerVak(srcBox); if (!vak) throw new Error('vak te klein of ontaard'); @@ -215,6 +263,9 @@ export async function bedKnipselIn(doelDoc, bronBytes, srcBox, paginaIndex = 0, }; // Identiteitsmatrix: de wrappers knippen in oorspronkelijke PDF-coördinaten. const bronForm = await doelDoc.embedPage(pagina, volledig, [1, 0, 0, 1, 0, 0]); + let forms = bronForms.get(doelDoc); + if (!forms) bronForms.set(doelDoc, forms = new Set()); + forms.add(bronForm); return { bronForm, rotatie: paginaRotatie(pagina), vakken: new Map() }; })(); bladen.set(paginaIndex, laden); diff --git a/open-pdf-studio/js/tools/tools/vector-snippet-tool.js b/open-pdf-studio/js/tools/tools/vector-snippet-tool.js index 8798954b2..e9fa6193c 100644 --- a/open-pdf-studio/js/tools/tools/vector-snippet-tool.js +++ b/open-pdf-studio/js/tools/tools/vector-snippet-tool.js @@ -80,9 +80,9 @@ export async function knipselVanVak(vak, paginaNr) { const srcBox = appVakNaarPdfVak(vak, pagina.getCropBox(), paginaRotatie(pagina) + appRotatie); if (!srcBox) return { fout: 'vak te klein' }; - // De hele bronpagina gaat mee, niet het bijgesneden vak: dat is wat het - // inbedden nodig heeft. Zie js/pdf/vector-embed.js. - const mini = await knipselAlsMiniPdf(bronBytes, paginaNr - 1, appRotatie, bron); + // De bronpagina gaat mee op volle maat (srcBox blijft in haar coördinaten), + // maar teruggesnoeid tot wat het vak raakt (#509). Zie js/pdf/vector-cull.js. + const mini = await knipselAlsMiniPdf(bronBytes, paginaNr - 1, appRotatie, bron, srcBox); const sleutel = bewaar(mini); return { diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index 854e35572..d65cf2e47 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -116,6 +116,7 @@ js/search/search-sources.test.mjs js/solid/stores/panels/annotationsStore.test.mjs js/solid/stores/print-instellingen.test.mjs js/pdf/vector-embed.test.mjs +js/pdf/vector-cull.test.mjs js/pdf/saver/vector-snippet.test.mjs js/pdf/loader/vector-snippet-load.test.mjs js/annotations/vector-snippet-store.test.mjs From 6a512955de5859e3a3c7c8cc849506dba7b2e849 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 09:52:48 +0100 Subject: [PATCH 02/13] feat(annotations): filterable annotation summary with CSV and PDF report (#769) The comments panel gets a filter bar for author, type, page range and date range, alongside the existing status filter, plus a clear-filters action and a bulk status change for the selection or all listed comments in one undo step. Sorting and grouping move into a pure module shared by the list and the exports. The CSV export now carries all fields (ids, dates, status history, replies, layer and bounds), and "Summarize Comments" builds a PDF report of the listed comments with a crop of each comment's location. Fixes #383 Review: included as is; no changes were needed on top. Note that the CSV column set has changed. --- .../js/annotations/opmerkingen-export.js | 36 ++- .../annotations/opmerkingen-export.test.mjs | 45 ++- .../js/annotations/opmerkingen-overzicht.js | 269 ++++++++++++++++++ .../opmerkingen-overzicht.test.mjs | 164 +++++++++++ .../js/annotations/opmerkingen-rapport.js | 254 +++++++++++++++++ .../annotations/opmerkingen-rapport.test.mjs | 155 ++++++++++ .../js/i18n/locales/ar/properties.json | 31 +- .../js/i18n/locales/bg/properties.json | 31 +- .../js/i18n/locales/bn/properties.json | 31 +- .../js/i18n/locales/ca/properties.json | 31 +- .../js/i18n/locales/cs/properties.json | 31 +- .../js/i18n/locales/da/properties.json | 31 +- .../js/i18n/locales/de/properties.json | 31 +- .../js/i18n/locales/el/properties.json | 31 +- .../js/i18n/locales/en/properties.json | 31 +- .../js/i18n/locales/es/properties.json | 31 +- .../js/i18n/locales/fa/properties.json | 31 +- .../js/i18n/locales/fi/properties.json | 31 +- .../js/i18n/locales/fr/properties.json | 31 +- .../js/i18n/locales/he/properties.json | 31 +- .../js/i18n/locales/hi/properties.json | 31 +- .../js/i18n/locales/hr/properties.json | 31 +- .../js/i18n/locales/hu/properties.json | 31 +- .../js/i18n/locales/id/properties.json | 31 +- .../js/i18n/locales/it/properties.json | 31 +- .../js/i18n/locales/ja/properties.json | 31 +- .../js/i18n/locales/ko/properties.json | 31 +- .../js/i18n/locales/lo/properties.json | 31 +- .../js/i18n/locales/ms/properties.json | 31 +- .../js/i18n/locales/nb/properties.json | 31 +- .../js/i18n/locales/nl/properties.json | 31 +- .../js/i18n/locales/pl/properties.json | 31 +- .../js/i18n/locales/pt/properties.json | 31 +- .../js/i18n/locales/ro/properties.json | 31 +- .../js/i18n/locales/ru/properties.json | 31 +- .../js/i18n/locales/sk/properties.json | 31 +- .../js/i18n/locales/sr/properties.json | 31 +- .../js/i18n/locales/sv/properties.json | 31 +- .../js/i18n/locales/sw/properties.json | 31 +- .../js/i18n/locales/ta/properties.json | 31 +- .../js/i18n/locales/th/properties.json | 31 +- .../js/i18n/locales/tr/properties.json | 31 +- .../js/i18n/locales/uk/properties.json | 31 +- .../js/i18n/locales/ur/properties.json | 31 +- .../js/i18n/locales/vi/properties.json | 31 +- .../js/i18n/locales/zh/properties.json | 31 +- .../left-panel/panels/AnnotationsPanel.jsx | 178 +++++++++++- .../solid/stores/panels/annotationsStore.js | 24 ++ .../stores/panels/annotationsStore.test.mjs | 20 ++ .../js/ui/panels/annotations-list.js | 99 ++----- .../js/ui/panels/annotations-summary.js | 180 ++++++++++++ open-pdf-studio/scripts/unit-testlijst.txt | 2 + open-pdf-studio/styles/panels.css | 63 ++++ 53 files changed, 2548 insertions(+), 181 deletions(-) create mode 100644 open-pdf-studio/js/annotations/opmerkingen-overzicht.js create mode 100644 open-pdf-studio/js/annotations/opmerkingen-overzicht.test.mjs create mode 100644 open-pdf-studio/js/annotations/opmerkingen-rapport.js create mode 100644 open-pdf-studio/js/annotations/opmerkingen-rapport.test.mjs create mode 100644 open-pdf-studio/js/ui/panels/annotations-summary.js diff --git a/open-pdf-studio/js/annotations/opmerkingen-export.js b/open-pdf-studio/js/annotations/opmerkingen-export.js index c65feea6c..c46947323 100644 --- a/open-pdf-studio/js/annotations/opmerkingen-export.js +++ b/open-pdf-studio/js/annotations/opmerkingen-export.js @@ -13,7 +13,8 @@ import { expandCorrectionGroups } from './corrections/model.js'; /** De kolommen van de CSV, in deze volgorde. */ export const CSV_KOLOMMEN = Object.freeze([ - 'page', 'type', 'author', 'date', 'status', 'subject', 'contents', 'color', 'layer', + 'id', 'page', 'type', 'author', 'created', 'modified', 'status', 'statusBy', 'statusAt', + 'subject', 'contents', 'replyCount', 'replies', 'color', 'layer', 'x', 'y', 'width', 'height', ]); // Een tekstveld dat zo begint leest een spreadsheet als formule. Uit een PDF @@ -48,31 +49,60 @@ export function csvDatum(datum) { + `${tweeCijfers(d.getHours())}:${tweeCijfers(d.getMinutes())}`; } -function inhoudVan(ann) { +export function inhoudVan(ann) { for (const veld of [ann.text, ann.stampText, ann.contents]) { if (typeof veld === 'string' && veld !== '') return veld; } return ''; } +/** De reacties als tekst: één regel per reactie, `auteur (datum): tekst`. */ +export function reactiesTekst(replies) { + return (Array.isArray(replies) ? replies : []) + .filter((r) => r && (r.text || r.author)) + .map((r) => { + const datum = csvDatum(r.createdAt || r.modifiedAt); + const wie = r.author || 'User'; + return `${wie}${datum ? ` (${datum})` : ''}: ${r.text || ''}`; + }) + .join('\n'); +} + +const tweeDecimalen = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : ''); + /** * De waarden van één annotatie in de volgorde van CSV_KOLOMMEN. * @param {object} ann * @param {{ typeNaam?: (ann) => string, statusNaam?: (status) => string, - * laagNaam?: (ann) => string }} [namen] + * laagNaam?: (ann) => string, + * omhullende?: (ann) => ({x:number, y:number, width:number, height:number}|null) }} [namen] */ export function opmerkingRij(ann, namen = {}) { const status = ann.status && String(ann.status).toLowerCase() !== 'none' ? String(ann.status) : ''; + const replies = Array.isArray(ann.replies) ? ann.replies.filter(Boolean) : []; + const b = (namen.omhullende ? namen.omhullende(ann) : null) + || (Number.isFinite(ann.x) && Number.isFinite(ann.y) + ? { x: ann.x, y: ann.y, width: ann.width, height: ann.height } : null); return [ + ann.id ?? '', ann.page ?? '', namen.typeNaam ? namen.typeNaam(ann) : (ann.type || ''), ann.author || '', + csvDatum(ann.createdAt), csvDatum(ann.modifiedAt || ann.createdAt), status && namen.statusNaam ? namen.statusNaam(status) : status, + status ? (ann.statusBy || '') : '', + status ? csvDatum(ann.statusAt) : '', ann.subject || '', inhoudVan(ann), + replies.length, + reactiesTekst(replies), ann.color || ann.strokeColor || '', namen.laagNaam ? (namen.laagNaam(ann) || '') : '', + b ? tweeDecimalen(b.x) : '', + b ? tweeDecimalen(b.y) : '', + b ? tweeDecimalen(b.width) : '', + b ? tweeDecimalen(b.height) : '', ]; } diff --git a/open-pdf-studio/js/annotations/opmerkingen-export.test.mjs b/open-pdf-studio/js/annotations/opmerkingen-export.test.mjs index 863241fac..a53734c5f 100644 --- a/open-pdf-studio/js/annotations/opmerkingen-export.test.mjs +++ b/open-pdf-studio/js/annotations/opmerkingen-export.test.mjs @@ -55,33 +55,58 @@ test('csvDatum: lokale tijd als JJJJ-MM-DD UU:MM, ongeldig wordt leeg', () => { }); test('opmerkingRij: alle kolommen in de vaste volgorde', () => { - assert.deepEqual(CSV_KOLOMMEN, - ['page', 'type', 'author', 'date', 'status', 'subject', 'contents', 'color', 'layer']); + assert.deepEqual(CSV_KOLOMMEN, [ + 'id', 'page', 'type', 'author', 'created', 'modified', 'status', 'statusBy', 'statusAt', + 'subject', 'contents', 'replyCount', 'replies', 'color', 'layer', 'x', 'y', 'width', 'height', + ]); const ann = { id: 'a1', page: 3, type: 'comment', author: 'Marieke', status: 'accepted', - modifiedAt: new Date(2026, 0, 2, 13, 45).toISOString(), createdAt: '2025-01-01T00:00:00Z', + statusBy: 'Joost', statusAt: new Date(2026, 0, 3, 9, 0).toISOString(), + modifiedAt: new Date(2026, 0, 2, 13, 45).toISOString(), createdAt: new Date(2025, 11, 31, 8, 5).toISOString(), subject: 'Gevel', text: 'Kozijn "B" vervangen,\nzie detail', color: '#FF0000', layer: 'l2', + replies: [ + { author: 'Joost', text: 'Mee eens', createdAt: new Date(2026, 0, 2, 14, 0).toISOString() }, + { text: 'Zonder auteur' }, + ], }; const rij = opmerkingRij(ann, { typeNaam: (a) => `T:${a.type}`, statusNaam: (s) => `S:${s}`, laagNaam: (a) => `L:${a.layer}`, + omhullende: () => ({ x: 10.123, y: 20, width: 30.456, height: 40 }), }); - assert.deepEqual(rij, [3, 'T:comment', 'Marieke', '2026-01-02 13:45', 'S:accepted', 'Gevel', - 'Kozijn "B" vervangen,\nzie detail', '#FF0000', 'L:l2']); + assert.deepEqual(rij, ['a1', 3, 'T:comment', 'Marieke', '2025-12-31 08:05', '2026-01-02 13:45', + 'S:accepted', 'Joost', '2026-01-03 09:00', 'Gevel', 'Kozijn "B" vervangen,\nzie detail', + 2, 'Joost (2026-01-02 14:00): Mee eens\nUser: Zonder auteur', '#FF0000', 'L:l2', 10.12, 20, 30.46, 40]); + assert.equal(rij.length, CSV_KOLOMMEN.length); }); test('opmerkingRij: ontbrekende velden worden leeg, stempeltekst telt als inhoud', () => { const rij = opmerkingRij({ id: 's', page: 1, type: 'stamp', stampText: 'APPROVED', strokeColor: '#00f' }, {}); - assert.deepEqual(rij, [1, 'stamp', '', '', '', '', 'APPROVED', '#00f', '']); + assert.deepEqual(rij, ['s', 1, 'stamp', '', '', '', '', '', '', '', 'APPROVED', 0, '', '#00f', '', '', '', '', '']); // Zonder wijzigingsdatum telt de aanmaakdatum. const r2 = opmerkingRij({ id: 'x', page: 2, type: 'box', createdAt: new Date(2026, 4, 6, 8, 9).toISOString() }, {}); - assert.equal(r2[3], '2026-05-06 08:09'); + assert.equal(r2[4], '2026-05-06 08:09'); + assert.equal(r2[5], '2026-05-06 08:09'); + // Zonder omhullende-functie gelden x/y/width/height van de annotatie zelf. + const r3 = opmerkingRij({ id: 'r', page: 1, type: 'box', x: 1, y: 2, width: 3, height: 4 }, {}); + assert.deepEqual(r3.slice(-4), [1, 2, 3, 4]); }); -test('opmerkingRij: geen status blijft leeg, ook niet via statusNaam', () => { - const rij = opmerkingRij({ id: 'x', page: 1, type: 'box', status: 'none' }, { statusNaam: (s) => `S:${s}` }); - assert.equal(rij[4], ''); +test('opmerkingRij: geen status blijft leeg, ook niet via statusNaam, en zonder stempel', () => { + const rij = opmerkingRij({ id: 'x', page: 1, type: 'box', status: 'none', statusBy: 'oud' }, { statusNaam: (s) => `S:${s}` }); + assert.equal(rij[6], ''); + assert.equal(rij[7], ''); +}); + +test('CSV met alle velden: reacties met regeleinden en komma\'s blijven één veld', () => { + const ann = { id: 'q', page: 1, type: 'comment', text: '=1+1', replies: [{ author: 'A, B', text: 'x "y"' }] }; + const csv = bouwCsv(CSV_KOLOMMEN, [opmerkingRij(ann)]); + const regels = csv.slice(1).split('\r\n'); + assert.equal(regels[0], CSV_KOLOMMEN.join(',')); + assert.ok(regels[1].includes(`"A, B: x ""y"""`)); + assert.ok(regels[1].includes(`'=1+1`)); + assert.equal(regels.length, 3); // kop, rij, lege rest na de laatste CRLF }); test('opmerkingenInLijstvolgorde: volgt de rijen van het paneel, kopregels vallen weg', () => { diff --git a/open-pdf-studio/js/annotations/opmerkingen-overzicht.js b/open-pdf-studio/js/annotations/opmerkingen-overzicht.js new file mode 100644 index 000000000..a91778cd5 --- /dev/null +++ b/open-pdf-studio/js/annotations/opmerkingen-overzicht.js @@ -0,0 +1,269 @@ +// Het opmerkingenoverzicht (#383): filteren, sorteren en de status van veel +// opmerkingen tegelijk zetten, plus de uitsnede van een opmerking voor het +// PDF-rapport. +// +// Puur: geen DOM, geen app-state. Het paneel (annotations-list.js) en het +// rapport (opmerkingen-rapport.js) rekenen allebei hiermee, zodat lijst, CSV +// en rapport dezelfde opmerkingen in dezelfde volgorde tonen. + +import { csvDatum } from './opmerkingen-export.js'; + +/** De auteur zoals de lijst hem toont: zonder auteur heet hij 'User'. */ +export function auteurVan(ann) { + return (ann && ann.author) || 'User'; +} + +/** De review-status in kleine letters; geen status telt als 'none'. */ +export function statusSleutel(ann) { + return String((ann && ann.status) || 'none').toLowerCase(); +} + +/** De datum waarop de datumfilter kijkt: gewijzigd, anders aangemaakt. */ +function filterDatum(ann) { + return csvDatum(ann.modifiedAt || ann.createdAt).slice(0, 10); +} + +/** Een leeg filter: alles zichtbaar. */ +export function leegFilter() { + return { auteur: '', type: '', paginaVan: null, paginaTot: null, datumVan: '', datumTot: '' }; +} + +function heelGetal(waarde) { + if (waarde === null || waarde === undefined || waarde === '') return null; + const n = Number(waarde); + return Number.isInteger(n) && n > 0 ? n : null; +} + +const ISO_DATUM = /^\d{4}-\d{2}-\d{2}$/; +const datumOfLeeg = (waarde) => (typeof waarde === 'string' && ISO_DATUM.test(waarde) ? waarde : ''); + +/** + * Maakt een filter schoon: lege of ongeldige waarden vallen weg, een + * omgekeerd bereik (van > tot) wordt omgedraaid. + */ +export function normaliseerFilter(filter) { + const f = { ...leegFilter(), ...(filter || {}) }; + const uit = { + auteur: f.auteur ? String(f.auteur) : '', + type: f.type ? String(f.type) : '', + paginaVan: heelGetal(f.paginaVan), + paginaTot: heelGetal(f.paginaTot), + datumVan: datumOfLeeg(f.datumVan), + datumTot: datumOfLeeg(f.datumTot), + }; + if (uit.paginaVan && uit.paginaTot && uit.paginaVan > uit.paginaTot) { + [uit.paginaVan, uit.paginaTot] = [uit.paginaTot, uit.paginaVan]; + } + if (uit.datumVan && uit.datumTot && uit.datumVan > uit.datumTot) { + [uit.datumVan, uit.datumTot] = [uit.datumTot, uit.datumVan]; + } + return uit; +} + +/** Staat er iets in het filter? */ +export function filterActief(filter) { + const f = normaliseerFilter(filter); + return !!(f.auteur || f.type || f.paginaVan || f.paginaTot || f.datumVan || f.datumTot); +} + +/** + * Voldoet een opmerking aan het filter? Over alle pagina's: het filter kent + * geen huidige pagina, alleen een paginabereik. + * @param {object} ann + * @param {object} filter zie leegFilter() + * @param {{ verborgenStatussen?: Set }} [opties] statussen (kleine + * letters) die Tonen > Status heeft uitgevinkt + */ +export function voldoetAanFilter(ann, filter, opties = {}) { + return voldoetGenormaliseerd(ann, normaliseerFilter(filter), opties); +} + +function voldoetGenormaliseerd(ann, f, opties) { + if (!ann) return false; + if (f.auteur && auteurVan(ann) !== f.auteur) return false; + if (f.type && ann.type !== f.type) return false; + const verborgen = opties.verborgenStatussen; + if (verborgen && verborgen.size > 0 && verborgen.has(statusSleutel(ann))) return false; + const pagina = Number(ann.page); + if (f.paginaVan && !(pagina >= f.paginaVan)) return false; + if (f.paginaTot && !(pagina <= f.paginaTot)) return false; + if (f.datumVan || f.datumTot) { + const datum = filterDatum(ann); + if (!datum) return false; + if (f.datumVan && datum < f.datumVan) return false; + if (f.datumTot && datum > f.datumTot) return false; + } + return true; +} + +/** De opmerkingen die aan het filter voldoen, in hun oorspronkelijke volgorde. */ +export function filterOpmerkingen(annotations, filter, opties = {}) { + const f = normaliseerFilter(filter); + return (annotations || []).filter((a) => voldoetGenormaliseerd(a, f, opties)); +} + +/** De auteurs in het document, alfabetisch (hoofdletter-ongevoelig), uniek. */ +export function auteursIn(annotations) { + const set = new Set((annotations || []).filter(Boolean).map(auteurVan)); + return [...set].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b)); +} + +/** De soorten in het document, uniek, gesorteerd op hun getoonde naam. */ +export function typesIn(annotations, typeNaam = (t) => t) { + const set = new Set((annotations || []).filter((a) => a && a.type).map((a) => a.type)); + return [...set] + .map((type) => ({ type, naam: typeNaam(type) })) + .sort((a, b) => a.naam.localeCompare(b.naam)); +} + +const tijd = (waarde) => { + const t = new Date(waarde).getTime(); + return Number.isNaN(t) ? 0 : t; +}; + +/** De auteur van de laatste reactie, anders de auteur van de opmerking. */ +export function laatsteStatusAuteur(ann) { + const replies = (ann && ann.replies) || []; + for (let i = replies.length - 1; i >= 0; i--) { + if (replies[i] && replies[i].author) return replies[i].author; + } + return auteurVan(ann); +} + +const paginaDanDatum = (a, b) => { + if (a.page !== b.page) return a.page - b.page; + return tijd(a.createdAt) - tijd(b.createdAt); +}; + +const opTekst = (sleutel) => (a, b) => { + const sa = sleutel(a); + const sb = sleutel(b); + if (sa !== sb) return sa.localeCompare(sb); + return paginaDanDatum(a, b); +}; + +/** + * De vergelijker voor een sorteer-/groeperingswijze van het paneel. + * @param {string} wijze page | type | modifiedDate | creationDate | author | + * color | subject | status | statusAndAuthor | lastStatusAuthor + * @param {{ typeNaam?: (type: string) => string }} [opties] + */ +export function vergelijkerVoor(wijze, opties = {}) { + const typeNaam = opties.typeNaam || ((t) => String(t || '')); + switch (wijze) { + case 'type': return opTekst((a) => typeNaam(a.type)); + case 'modifiedDate': return (a, b) => tijd(b.modifiedAt) - tijd(a.modifiedAt); + case 'creationDate': return (a, b) => tijd(b.createdAt) - tijd(a.createdAt); + case 'author': return opTekst((a) => auteurVan(a).toLowerCase()); + case 'color': return opTekst((a) => String(a.color || a.strokeColor || '#000').toLowerCase()); + case 'subject': return opTekst((a) => String(a.subject || '').toLowerCase()); + case 'status': return opTekst(statusSleutel); + case 'statusAndAuthor': return (a, b) => { + const sa = statusSleutel(a); + const sb = statusSleutel(b); + if (sa !== sb) return sa.localeCompare(sb); + return opTekst((x) => auteurVan(x).toLowerCase())(a, b); + }; + case 'lastStatusAuthor': return opTekst((a) => laatsteStatusAuteur(a).toLowerCase()); + default: return paginaDanDatum; + } +} + +/** Een gesorteerde kopie; de invoer blijft zoals hij was. */ +export function sorteerOpmerkingen(annotations, wijze, opties = {}) { + return [...(annotations || [])].filter(Boolean).sort(vergelijkerVoor(wijze, opties)); +} + +/** De statussen die het paneel in bulk kan zetten (kleine letters). */ +export const BULK_STATUSSEN = Object.freeze(['none', 'accepted', 'cancelled', 'completed', 'rejected']); + +/** + * De opmerkingen waarvan de status echt verandert: vergrendelde vallen af, + * net als opmerkingen die de status al hebben. Elke opmerking één keer. + */ +export function statusDoelen(annotations, status) { + const doel = String(status || 'none').toLowerCase(); + const gezien = new Set(); + const uit = []; + for (const ann of annotations || []) { + if (!ann || gezien.has(ann)) continue; + gezien.add(ann); + if (ann.locked) continue; + if (statusSleutel(ann) === doel) continue; + uit.push(ann); + } + return uit; +} + +/** + * Zet de status van één opmerking zoals het eigenschappenpaneel dat doet: + * 'none' haalt de status en haar stempel weg, een andere status legt vast + * door wie en wanneer. + */ +export function zetStatus(ann, status, { door = 'User', nu = new Date().toISOString() } = {}) { + const doel = String(status || 'none').toLowerCase(); + if (doel === 'none') { + ann.status = undefined; + delete ann.statusBy; + delete ann.statusAt; + } else { + ann.status = doel; + ann.statusBy = door; + ann.statusAt = nu; + } + ann.modifiedAt = nu; + return ann; +} + +/** + * Het stuk van de pagina dat het rapport bij een opmerking toont, in pixels + * van de pagina op `schaal` (zoals renderPageOffscreen het als `deel` wil). + * Rond de opmerking komt een marge, zodat te zien is waar ze ligt; het gebied + * blijft binnen de pagina. De schaal kiest zo dat de langste zijde ongeveer + * `maxPx` pixels wordt, begrensd tussen `minSchaal` en `maxSchaal`. + * @param {{x:number, y:number, width:number, height:number}|null} bounds + * in paginapunten (schaal 1, oorsprong linksboven) + * @param {{ breedte:number, hoogte:number }} pagina paginamaat in punten + * @returns {{ schaal:number, deel:{x:number, y:number, breedte:number, hoogte:number}, + * kader:{x:number, y:number, breedte:number, hoogte:number} } | null} + * kader: de opmerking zelf binnen het deel, in pixels van het deel + */ +export function uitsnedeVoor(bounds, pagina, { maxPx = 480, minSchaal = 0.5, maxSchaal = 4, minMarge = 24 } = {}) { + const pw = Number(pagina && pagina.breedte); + const ph = Number(pagina && pagina.hoogte); + if (!(pw > 0) || !(ph > 0)) return null; + let b = bounds; + if (!b || ![b.x, b.y, b.width, b.height].every(Number.isFinite)) return null; + // Een negatieve maat (van rechts naar links getekend) eerst rechtzetten. + b = { + x: Math.min(b.x, b.x + b.width), y: Math.min(b.y, b.y + b.height), + width: Math.abs(b.width), height: Math.abs(b.height), + }; + const marge = Math.max(minMarge, Math.max(b.width, b.height) * 0.15); + let x0 = Math.max(0, b.x - marge); + let y0 = Math.max(0, b.y - marge); + let x1 = Math.min(pw, b.x + b.width + marge); + let y1 = Math.min(ph, b.y + b.height + marge); + if (x1 - x0 < 1 || y1 - y0 < 1) { + // Helemaal buiten de pagina: toon de hoek waar ze het dichtst bij ligt. + x0 = Math.max(0, Math.min(pw - 1, b.x)); + y0 = Math.max(0, Math.min(ph - 1, b.y)); + x1 = Math.min(pw, x0 + Math.max(1, 2 * marge)); + y1 = Math.min(ph, y0 + Math.max(1, 2 * marge)); + } + const langste = Math.max(x1 - x0, y1 - y0); + const schaal = Math.min(maxSchaal, Math.max(minSchaal, maxPx / langste)); + const deel = { + x: Math.floor(x0 * schaal), + y: Math.floor(y0 * schaal), + breedte: Math.max(1, Math.ceil((x1 - x0) * schaal)), + hoogte: Math.max(1, Math.ceil((y1 - y0) * schaal)), + }; + const kader = { + x: b.x * schaal - deel.x, + y: b.y * schaal - deel.y, + breedte: b.width * schaal, + hoogte: b.height * schaal, + }; + return { schaal, deel, kader }; +} diff --git a/open-pdf-studio/js/annotations/opmerkingen-overzicht.test.mjs b/open-pdf-studio/js/annotations/opmerkingen-overzicht.test.mjs new file mode 100644 index 000000000..1b70ffe11 --- /dev/null +++ b/open-pdf-studio/js/annotations/opmerkingen-overzicht.test.mjs @@ -0,0 +1,164 @@ +// Het opmerkingenoverzicht (#383): filteren over alle pagina's, sorteren, +// status in bulk en de uitsnede voor het rapport. + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + auteurVan, statusSleutel, leegFilter, normaliseerFilter, filterActief, + voldoetAanFilter, filterOpmerkingen, auteursIn, typesIn, laatsteStatusAuteur, + vergelijkerVoor, sorteerOpmerkingen, statusDoelen, zetStatus, BULK_STATUSSEN, uitsnedeVoor, +} from './opmerkingen-overzicht.js'; + +const iso = (j, m, d, u = 12) => new Date(j, m - 1, d, u, 0).toISOString(); + +const ANNS = [ + { id: 'a', page: 1, type: 'comment', author: 'Anna', status: 'accepted', createdAt: iso(2026, 3, 1), modifiedAt: iso(2026, 3, 2) }, + { id: 'b', page: 7, type: 'box', author: 'Bram', createdAt: iso(2026, 3, 5) }, + { id: 'c', page: 3, type: 'comment', author: 'Anna', status: 'Rejected', createdAt: iso(2026, 2, 1) }, + { id: 'd', page: 12, type: 'arrow', createdAt: iso(2026, 4, 1) }, + { id: 'e', page: 3, type: 'box', author: 'anna', status: 'completed', createdAt: iso(2026, 3, 10) }, +]; +const ids = (lijst) => lijst.map((a) => a.id); + +test('auteurVan en statusSleutel: standaardwaarden zoals de lijst', () => { + assert.equal(auteurVan({}), 'User'); + assert.equal(auteurVan({ author: 'X' }), 'X'); + assert.equal(statusSleutel({}), 'none'); + assert.equal(statusSleutel({ status: 'Accepted' }), 'accepted'); +}); + +test('leeg filter laat alles door', () => { + assert.equal(filterActief(leegFilter()), false); + assert.deepEqual(ids(filterOpmerkingen(ANNS, leegFilter())), ['a', 'b', 'c', 'd', 'e']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, null)), ['a', 'b', 'c', 'd', 'e']); +}); + +test('filter op auteur werkt over alle pagina\'s, exact en hoofdlettergevoelig', () => { + const f = { auteur: 'Anna' }; + assert.equal(filterActief(f), true); + assert.deepEqual(ids(filterOpmerkingen(ANNS, f)), ['a', 'c']); + // Zonder auteur heet hij 'User', ook in het filter. + assert.deepEqual(ids(filterOpmerkingen(ANNS, { auteur: 'User' })), ['d']); +}); + +test('filter op status (Tonen > Status) werkt over alle pagina\'s, hoofdletter-ongevoelig', () => { + const verborgenStatussen = new Set(['rejected', 'none']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, leegFilter(), { verborgenStatussen })), ['a', 'e']); +}); + +test('auteur en status samen', () => { + const uit = filterOpmerkingen(ANNS, { auteur: 'Anna' }, { verborgenStatussen: new Set(['accepted']) }); + assert.deepEqual(ids(uit), ['c']); +}); + +test('filter op soort', () => { + assert.deepEqual(ids(filterOpmerkingen(ANNS, { type: 'box' })), ['b', 'e']); +}); + +test('paginabereik: grenzen tellen mee, omgekeerd bereik wordt omgedraaid', () => { + assert.deepEqual(ids(filterOpmerkingen(ANNS, { paginaVan: 3, paginaTot: 7 })), ['b', 'c', 'e']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, { paginaVan: '7', paginaTot: '3' })), ['b', 'c', 'e']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, { paginaVan: 10 })), ['d']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, { paginaTot: 1 })), ['a']); + // Ongeldige invoer telt als leeg. + assert.deepEqual(normaliseerFilter({ paginaVan: 'x', paginaTot: -2 }).paginaVan, null); + assert.equal(filterActief({ paginaVan: '', datumVan: 'gisteren' }), false); +}); + +test('datumbereik: gewijzigd, anders aangemaakt; grenzen inclusief', () => { + // a is gewijzigd op 2 maart: telt op die datum, niet op 1 maart. + assert.deepEqual(ids(filterOpmerkingen(ANNS, { datumVan: '2026-03-02', datumTot: '2026-03-05' })), ['a', 'b']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, { datumTot: '2026-02-28' })), ['c']); + assert.deepEqual(ids(filterOpmerkingen(ANNS, { datumVan: '2026-04-01' })), ['d']); + // Zonder datum valt een opmerking buiten elk datumbereik. + assert.equal(voldoetAanFilter({ id: 'z', page: 1 }, { datumVan: '2000-01-01' }), false); +}); + +test('keuzelijsten: auteurs en soorten uniek en gesorteerd', () => { + assert.deepEqual(auteursIn(ANNS), ['anna', 'Anna', 'Bram', 'User']); + assert.deepEqual(typesIn(ANNS, (t) => t.toUpperCase()), [ + { type: 'arrow', naam: 'ARROW' }, { type: 'box', naam: 'BOX' }, { type: 'comment', naam: 'COMMENT' }, + ]); +}); + +test('sorteren op pagina, dan aanmaakdatum (standaard)', () => { + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'page')), ['a', 'c', 'e', 'b', 'd']); + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'onbekend')), ['a', 'c', 'e', 'b', 'd']); +}); + +test('sorteren op auteur (hoofdletter-ongevoelig), status, soort en datums', () => { + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'author')), ['a', 'c', 'e', 'b', 'd']); + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'status')), ['a', 'e', 'b', 'd', 'c']); + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'type')), ['d', 'e', 'b', 'a', 'c']); + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'creationDate')), ['d', 'e', 'b', 'a', 'c']); + // Zonder wijzigingsdatum telt een opmerking als oudst. + assert.equal(ids(sorteerOpmerkingen(ANNS, 'modifiedDate'))[0], 'a'); + assert.deepEqual(ids(sorteerOpmerkingen(ANNS, 'statusAndAuthor')), ['a', 'e', 'b', 'd', 'c']); +}); + +test('sorteren geeft een kopie en laat de invoer staan', () => { + const invoer = [...ANNS]; + sorteerOpmerkingen(invoer, 'author'); + assert.deepEqual(ids(invoer), ['a', 'b', 'c', 'd', 'e']); + assert.equal(typeof vergelijkerVoor('color'), 'function'); +}); + +test('laatste-statusauteur: de laatste reactie met auteur, anders de auteur', () => { + assert.equal(laatsteStatusAuteur({ author: 'A', replies: [{ author: 'B' }, { text: 'x' }] }), 'B'); + assert.equal(laatsteStatusAuteur({ author: 'A' }), 'A'); + const lijst = [ + { id: 'p', page: 1, author: 'Z', replies: [{ author: 'Bea' }] }, + { id: 'q', page: 1, author: 'Ad' }, + ]; + assert.deepEqual(ids(sorteerOpmerkingen(lijst, 'lastStatusAuthor')), ['q', 'p']); +}); + +test('statusDoelen: vergrendeld, al goed of dubbel valt af', () => { + const a = { id: 'a', status: 'Accepted' }; + const b = { id: 'b' }; + const c = { id: 'c', locked: true }; + assert.deepEqual(ids(statusDoelen([a, b, c, b, null], 'accepted')), ['b']); + assert.deepEqual(ids(statusDoelen([a, b, c], 'none')), ['a']); + assert.deepEqual([...BULK_STATUSSEN], ['none', 'accepted', 'cancelled', 'completed', 'rejected']); +}); + +test('zetStatus: stempelt wie en wanneer; none haalt alles weg', () => { + const ann = { id: 'x' }; + zetStatus(ann, 'Completed', { door: 'Kees', nu: '2026-10-27T10:00:00.000Z' }); + assert.deepEqual(ann, { id: 'x', status: 'completed', statusBy: 'Kees', statusAt: '2026-10-27T10:00:00.000Z', modifiedAt: '2026-10-27T10:00:00.000Z' }); + zetStatus(ann, 'none', { nu: '2026-10-28T10:00:00.000Z' }); + assert.equal(ann.status, undefined); + assert.equal('statusBy' in ann, false); + assert.equal('statusAt' in ann, false); + assert.equal(ann.modifiedAt, '2026-10-28T10:00:00.000Z'); +}); + +test('uitsnedeVoor: marge rond de opmerking, binnen de pagina, kader in het deel', () => { + const u = uitsnedeVoor({ x: 100, y: 200, width: 100, height: 50 }, { breedte: 595, hoogte: 842 }); + // marge = max(24, 15% van 100) = 24; gebied 76..224 x 176..274 (148 x 98). + assert.ok(Math.abs(u.schaal - 480 / 148) < 1e-9); + assert.equal(u.deel.x, Math.floor(76 * u.schaal)); + assert.equal(u.deel.y, Math.floor(176 * u.schaal)); + assert.ok(Math.abs(u.kader.x - (100 * u.schaal - u.deel.x)) < 1e-9); + assert.ok(Math.abs(u.kader.breedte - 100 * u.schaal) < 1e-9); +}); + +test('uitsnedeVoor: aan de rand afgekapt, schaal begrensd, ongeldig geeft null', () => { + const rand = uitsnedeVoor({ x: 0, y: 0, width: 10, height: 10 }, { breedte: 100, hoogte: 100 }, { maxPx: 10000 }); + assert.equal(rand.deel.x, 0); + assert.equal(rand.deel.y, 0); + assert.equal(rand.schaal, 4); // maxSchaal + const groot = uitsnedeVoor({ x: 0, y: 0, width: 2000, height: 2000 }, { breedte: 2000, hoogte: 2000 }); + assert.equal(groot.schaal, 0.5); // minSchaal + assert.equal(groot.deel.breedte, 1000); + // Negatieve maat wordt rechtgezet. + const neg = uitsnedeVoor({ x: 200, y: 200, width: -100, height: -50 }, { breedte: 595, hoogte: 842 }); + const pos = uitsnedeVoor({ x: 100, y: 150, width: 100, height: 50 }, { breedte: 595, hoogte: 842 }); + assert.deepEqual(neg, pos); + assert.equal(uitsnedeVoor(null, { breedte: 1, hoogte: 1 }), null); + assert.equal(uitsnedeVoor({ x: 0, y: 0, width: 1, height: 1 }, { breedte: 0, hoogte: 1 }), null); + // Buiten de pagina: toch een gebied binnen de pagina. + const buiten = uitsnedeVoor({ x: 900, y: 900, width: 10, height: 10 }, { breedte: 100, hoogte: 100 }); + assert.ok(buiten.deel.x >= 0 && buiten.deel.x + buiten.deel.breedte <= 100 * buiten.schaal + 1); +}); diff --git a/open-pdf-studio/js/annotations/opmerkingen-rapport.js b/open-pdf-studio/js/annotations/opmerkingen-rapport.js new file mode 100644 index 000000000..dac497009 --- /dev/null +++ b/open-pdf-studio/js/annotations/opmerkingen-rapport.js @@ -0,0 +1,254 @@ +// Het PDF-rapport van het opmerkingenoverzicht (#383): elke opmerking met +// haar paginanummer, soort, auteur, datum, status, tekst, reacties en een +// uitsnede van de plek op de pagina. +// +// Puur op pdf-lib: geen DOM, geen app-state. De uitsneden komen als PNG- of +// JPEG-bytes binnen (de app rendert ze met renderPageOffscreen, zie +// ui/panels/annotations-summary.js); vertalingen komen als labels binnen. + +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; + +const A4 = Object.freeze({ breedte: 595.28, hoogte: 841.89 }); +const MARGE = 40; +const UITSNEDE_B = 170; +const UITSNEDE_H = 130; +const TUSSENRUIMTE = 12; +const VOET = 24; + +const ZWART = rgb(0.1, 0.1, 0.1); +const GRIJS = rgb(0.4, 0.4, 0.4); +const LIJN = rgb(0.8, 0.8, 0.8); +const KADER = rgb(0.6, 0.6, 0.6); + +export const STANDAARD_LABELS = Object.freeze({ + titel: 'Comment Summary', + document: 'Document', + gemaakt: 'Created', + aantal: 'Comments', + pagina: 'Page', + auteur: 'Author', + datum: 'Date', + status: 'Status', + onderwerp: 'Subject', + reacties: 'Replies', + geenTekst: '(no comment text)', +}); + +/** + * Tekst die het font kan tonen: tabs worden spaties, tekens zonder glyph + * een vraagteken. Regeleinden blijven staan (wrapTekst splitst erop). + */ +export function veiligeTekst(tekst, tekenset) { + let uit = ''; + for (const teken of String(tekst ?? '').replace(/\r\n?/g, '\n').replace(/\t/g, ' ')) { + if (teken === '\n') { uit += teken; continue; } + const cp = teken.codePointAt(0); + uit += !tekenset || tekenset.has(cp) ? teken : '?'; + } + return uit; +} + +/** + * Breekt tekst af op een breedte: eerst op regeleinden, dan op woorden; een + * woord dat alleen al te breed is gaat per teken. + * @param {string} tekst + * @param {(s: string) => number} breedteVan + * @param {number} max + * @returns {string[]} + */ +export function wrapTekst(tekst, breedteVan, max) { + const regels = []; + for (const alinea of String(tekst ?? '').split('\n')) { + const woorden = alinea.split(/ +/).filter((w) => w !== ''); + if (woorden.length === 0) { regels.push(''); continue; } + let regel = ''; + for (let woord of woorden) { + const kandidaat = regel ? `${regel} ${woord}` : woord; + if (breedteVan(kandidaat) <= max) { regel = kandidaat; continue; } + if (regel) regels.push(regel); + regel = ''; + while (breedteVan(woord) > max && woord.length > 1) { + let n = 1; + const tekens = [...woord]; + while (n < tekens.length && breedteVan(tekens.slice(0, n + 1).join('')) <= max) n++; + regels.push(tekens.slice(0, n).join('')); + woord = tekens.slice(n).join(''); + } + regel = woord; + } + regels.push(regel); + } + return regels; +} + +/** Past een plaatje binnen een vak, zonder te vergroten. */ +export function pasIn(breedte, hoogte, maxB, maxH) { + if (!(breedte > 0) || !(hoogte > 0)) return { breedte: 0, hoogte: 0 }; + const f = Math.min(1, maxB / breedte, maxH / hoogte); + return { breedte: breedte * f, hoogte: hoogte * f }; +} + +async function standaardFonts(pdf) { + return { + normaal: await pdf.embedFont(StandardFonts.Helvetica), + vet: await pdf.embedFont(StandardFonts.HelveticaBold), + }; +} + +async function plaatjeVan(pdf, uitsnede) { + if (!uitsnede || !uitsnede.bytes) return null; + try { + return uitsnede.soort === 'jpeg' ? await pdf.embedJpg(uitsnede.bytes) : await pdf.embedPng(uitsnede.bytes); + } catch { + return null; + } +} + +/** + * Bouwt het rapport. + * @param {{ + * documentNaam?: string, + * gemaakt?: string, + * labels?: Partial, + * voettekst?: (n: number, totaal: number) => string, + * ingangen: Array<{ + * pagina: number, type?: string, auteur?: string, datum?: string, + * status?: string, onderwerp?: string, inhoud?: string, + * reacties?: Array<{ auteur?: string, datum?: string, tekst?: string }>, + * uitsnede?: { bytes: Uint8Array, soort: 'png'|'jpeg' } | null, + * }>, + * kiesFonts?: (pdf: PDFDocument) => Promise<{ normaal: any, vet?: any } | null>, + * }} invoer + * @returns {Promise<{ bytes: Uint8Array, paginas: number, + * ingangen: Array<{ nummer: number, pagina: number, rapportPagina: number, uitsnede: boolean }> }>} + */ +export async function bouwOpmerkingenRapport(invoer) { + const labels = { ...STANDAARD_LABELS, ...((invoer && invoer.labels) || {}) }; + const ingangen = (invoer && invoer.ingangen) || []; + const voettekst = (invoer && invoer.voettekst) || ((n, m) => `${n} / ${m}`); + + const pdf = await PDFDocument.create(); + pdf.setTitle(labels.titel); + pdf.setCreator('Open PDF Studio'); + pdf.setProducer('Open PDF Studio'); + + let fonts = null; + if (invoer && invoer.kiesFonts) { + try { fonts = await invoer.kiesFonts(pdf); } catch { fonts = null; } + } + if (!fonts || !fonts.normaal) fonts = await standaardFonts(pdf); + const normaal = fonts.normaal; + const vet = fonts.vet || fonts.normaal; + const tekenset = (font) => new Set(font.getCharacterSet()); + const sets = new Map([[normaal, tekenset(normaal)]]); + if (!sets.has(vet)) sets.set(vet, tekenset(vet)); + const veilig = (tekst, font) => veiligeTekst(tekst, sets.get(font)); + + const tekstX = MARGE + UITSNEDE_B + TUSSENRUIMTE; + const tekstB = A4.breedte - MARGE - tekstX; + const onderGrens = MARGE + VOET; + + let pagina = null; + let y = 0; + let paginaLeeg = true; + const nieuwePagina = (eerste) => { + pagina = pdf.addPage([A4.breedte, A4.hoogte]); + paginaLeeg = true; + y = A4.hoogte - MARGE; + if (eerste) { + const titel = veilig(labels.titel, vet); + pagina.drawText(titel, { x: MARGE, y: y - 16, size: 16, font: vet, color: ZWART }); + y -= 28; + const regels = [ + invoer.documentNaam ? `${labels.document}: ${invoer.documentNaam}` : null, + invoer.gemaakt ? `${labels.gemaakt}: ${invoer.gemaakt}` : null, + `${labels.aantal}: ${ingangen.length}`, + ].filter(Boolean); + for (const r of regels) { + pagina.drawText(veilig(r, normaal), { x: MARGE, y: y - 9, size: 9, font: normaal, color: GRIJS }); + y -= 13; + } + } else { + const kop = [labels.titel, invoer.documentNaam].filter(Boolean).join(' - '); + pagina.drawText(veilig(kop, normaal), { x: MARGE, y: y - 8, size: 8, font: normaal, color: GRIJS }); + y -= 14; + } + pagina.drawLine({ start: { x: MARGE, y }, end: { x: A4.breedte - MARGE, y }, thickness: 0.75, color: LIJN }); + y -= 10; + }; + nieuwePagina(true); + + const uitslag = []; + for (let i = 0; i < ingangen.length; i++) { + const ing = ingangen[i] || {}; + const nummer = i + 1; + const plaatje = await plaatjeVan(pdf, ing.uitsnede); + const maat = plaatje ? pasIn(plaatje.width, plaatje.height, UITSNEDE_B, UITSNEDE_H) : null; + + // Alle regels van de tekstkolom: [tekst, font, grootte, kleur, inspring]. + const regels = []; + const voeg = (tekst, font, grootte, kleur, inspring = 0) => { + for (const r of wrapTekst(veilig(tekst, font), (s) => font.widthOfTextAtSize(s, grootte), tekstB - inspring)) { + regels.push({ tekst: r, font, grootte, kleur, inspring }); + } + }; + voeg(`#${nummer} ${labels.pagina} ${ing.pagina ?? ''}${ing.type ? ` - ${ing.type}` : ''}`, vet, 10, ZWART); + const meta = [ + ing.auteur ? `${labels.auteur}: ${ing.auteur}` : null, + ing.datum ? `${labels.datum}: ${ing.datum}` : null, + ing.status ? `${labels.status}: ${ing.status}` : null, + ].filter(Boolean).join(' | '); + if (meta) voeg(meta, normaal, 8.5, GRIJS); + if (ing.onderwerp) voeg(`${labels.onderwerp}: ${ing.onderwerp}`, normaal, 9, ZWART); + if (ing.inhoud) voeg(ing.inhoud, normaal, 9, ZWART); + else voeg(labels.geenTekst, normaal, 9, GRIJS); + const reacties = (ing.reacties || []).filter(Boolean); + if (reacties.length > 0) { + voeg(`${labels.reacties} (${reacties.length})`, vet, 8.5, ZWART); + for (const r of reacties) { + const wie = `${r.auteur || 'User'}${r.datum ? ` (${r.datum})` : ''}`; + voeg(`${wie}: ${r.tekst || ''}`, normaal, 8.5, ZWART, 8); + } + } + const regelHoogte = (r) => r.grootte * 1.3; + let tekstH = regels.reduce((s, r) => s + regelHoogte(r), 0); + const blokH = () => Math.max(maat ? maat.hoogte : 0, tekstH) + 12; + + if (y - blokH() < onderGrens && !paginaLeeg) nieuwePagina(false); + // Past het blok zelfs op een lege pagina niet, dan wordt de tekst ingekort. + const ruimte = y - onderGrens - 12; + if (tekstH > ruimte) { + while (regels.length > 1 && tekstH > ruimte) tekstH -= regelHoogte(regels.pop()); + const laatste = regels[regels.length - 1]; + laatste.tekst = `${laatste.tekst} ...`; + } + + const boven = y; + if (plaatje && maat) { + const px = MARGE + (UITSNEDE_B - maat.breedte) / 2; + const py = boven - maat.hoogte; + pagina.drawImage(plaatje, { x: px, y: py, width: maat.breedte, height: maat.hoogte }); + pagina.drawRectangle({ x: px, y: py, width: maat.breedte, height: maat.hoogte, borderColor: KADER, borderWidth: 0.5 }); + } + let ty = boven; + for (const r of regels) { + ty -= regelHoogte(r); + pagina.drawText(r.tekst, { x: tekstX + r.inspring, y: ty + r.grootte * 0.25, size: r.grootte, font: r.font, color: r.kleur }); + } + y = boven - blokH(); + pagina.drawLine({ start: { x: MARGE, y: y + 5 }, end: { x: A4.breedte - MARGE, y: y + 5 }, thickness: 0.5, color: LIJN }); + + paginaLeeg = false; + uitslag.push({ nummer, pagina: ing.pagina, rapportPagina: pdf.getPageCount(), uitsnede: !!plaatje }); + } + + const paginas = pdf.getPages(); + paginas.forEach((p, i) => { + const tekst = veilig(voettekst(i + 1, paginas.length), normaal); + const b = normaal.widthOfTextAtSize(tekst, 8); + p.drawText(tekst, { x: A4.breedte - MARGE - b, y: MARGE - 8, size: 8, font: normaal, color: GRIJS }); + }); + + const bytes = await pdf.save(); + return { bytes, paginas: paginas.length, ingangen: uitslag }; +} diff --git a/open-pdf-studio/js/annotations/opmerkingen-rapport.test.mjs b/open-pdf-studio/js/annotations/opmerkingen-rapport.test.mjs new file mode 100644 index 000000000..ba268e7a5 --- /dev/null +++ b/open-pdf-studio/js/annotations/opmerkingen-rapport.test.mjs @@ -0,0 +1,155 @@ +// Het PDF-rapport van het opmerkingenoverzicht (#383): elke opmerking één +// ingang met haar paginanummer en een ingebedde uitsnede van haar plek. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import zlib from 'node:zlib'; +import { PDFDocument, PDFName, PDFDict, PDFRawStream } from 'pdf-lib'; + +import { bouwOpmerkingenRapport, veiligeTekst, wrapTekst, pasIn } from './opmerkingen-rapport.js'; + +// Een echte (kleine) PNG, zodat pdf-lib hem moet kunnen inbedden. +function maakPng(breedte, hoogte) { + const crcTabel = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; + }); + const crc = (buf) => { + let c = 0xffffffff; + for (const b of buf) c = crcTabel[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + }; + const brok = (soort, data) => { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(soort, 'ascii'), data]); + const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); + return Buffer.concat([len, td, c]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(breedte, 0); ihdr.writeUInt32BE(hoogte, 4); + ihdr[8] = 8; ihdr[9] = 2; // 8 bit, RGB + const rijen = []; + for (let y = 0; y < hoogte; y++) { + rijen.push(Buffer.from([0])); + for (let x = 0; x < breedte; x++) rijen.push(Buffer.from([200, 30 + x, 30 + y])); + } + return new Uint8Array(Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + brok('IHDR', ihdr), brok('IDAT', zlib.deflateSync(Buffer.concat(rijen))), brok('IEND', Buffer.alloc(0)), + ])); +} + +async function tekstPerPagina(bytes) { + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const taak = pdfjs.getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }); + const doc = await taak.promise; + const uit = []; + for (let i = 1; i <= doc.numPages; i++) { + const inhoud = await (await doc.getPage(i)).getTextContent(); + uit.push(inhoud.items.map((it) => it.str).join('\n')); + } + await taak.destroy(); + return uit; +} + +async function plaatjesPerPagina(bytes) { + const pdf = await PDFDocument.load(bytes); + return pdf.getPages().map((p) => { + const xo = p.node.Resources()?.lookup(PDFName.of('XObject'), PDFDict); + if (!xo) return 0; + return xo.keys().filter((k) => { + const s = xo.lookup(k); + return s instanceof PDFRawStream && s.dict.get(PDFName.of('Subtype'))?.toString() === '/Image'; + }).length; + }); +} + +const png = maakPng(40, 30); + +test('elke opmerking één ingang, met paginanummer en ingebedde uitsnede', async () => { + const ingangen = [ + { pagina: 3, type: 'Comment', auteur: 'Anna', datum: '2026-03-02 12:00', status: 'Accepted', inhoud: 'Kozijn vervangen', uitsnede: { bytes: png, soort: 'png' } }, + { pagina: 7, type: 'Rectangle', auteur: 'Bram', inhoud: '', uitsnede: { bytes: png, soort: 'png' } }, + { pagina: 12, type: 'Arrow', auteur: 'Cor', inhoud: 'Zie detail', reacties: [{ auteur: 'Anna', datum: '2026-03-03 09:00', tekst: 'Akkoord' }], uitsnede: { bytes: png, soort: 'png' } }, + ]; + const { bytes, paginas, ingangen: uit } = await bouwOpmerkingenRapport({ + documentNaam: 'tekening.pdf', gemaakt: '2026-10-27 10:00', ingangen, + }); + assert.equal(paginas, 1); + assert.deepEqual(uit.map((u) => [u.nummer, u.pagina, u.uitsnede]), [[1, 3, true], [2, 7, true], [3, 12, true]]); + + const [tekst] = await tekstPerPagina(bytes); + assert.match(tekst, /Comment Summary/); + assert.match(tekst, /tekening\.pdf/); + assert.match(tekst, /Comments: 3/); + assert.match(tekst, /#1 Page 3 - Comment/); + assert.match(tekst, /#2 Page 7/); + assert.match(tekst, /#3 Page 12/); + assert.match(tekst, /Status: Accepted/); + assert.match(tekst, /Kozijn vervangen/); + assert.match(tekst, /\(no comment text\)/); + assert.match(tekst, /Replies \(1\)/); + assert.match(tekst, /Anna \(2026-03-03 09:00\): Akkoord/); + + assert.deepEqual(await plaatjesPerPagina(bytes), [3]); +}); + +test('veel opmerkingen lopen over meer pagina\'s; elke ingang staat er één keer', async () => { + const ingangen = Array.from({ length: 25 }, (_, i) => ({ + pagina: i + 1, type: 'Note', auteur: 'A', inhoud: `Opmerking nummer ${i + 1}`, + uitsnede: i % 2 === 0 ? { bytes: png, soort: 'png' } : null, + })); + const { bytes, paginas, ingangen: uit } = await bouwOpmerkingenRapport({ ingangen, labels: { pagina: 'Pagina' } }); + assert.ok(paginas > 1); + assert.equal(uit.length, 25); + const teksten = await tekstPerPagina(bytes); + const alles = teksten.join('\n'); + for (let i = 1; i <= 25; i++) { + const treffers = alles.match(new RegExp(`#${i} Pagina ${i}(?!\\d)`, 'g')) || []; + assert.equal(treffers.length, 1, `ingang ${i}`); + // De ingang staat op de rapportpagina die de bouwer opgeeft. + assert.match(teksten[uit[i - 1].rapportPagina - 1], new RegExp(`Opmerking nummer ${i}(?!\\d)`)); + } + // Voettekst met paginanummering op elke pagina. + teksten.forEach((t, i) => assert.match(t, new RegExp(`${i + 1} / ${paginas}`))); + const plaatjes = await plaatjesPerPagina(bytes); + assert.equal(plaatjes.reduce((s, n) => s + n, 0), 13); +}); + +test('een onleesbare uitsnede breekt het rapport niet: de ingang blijft, zonder plaatje', async () => { + const { bytes, ingangen } = await bouwOpmerkingenRapport({ + ingangen: [{ pagina: 2, inhoud: 'x', uitsnede: { bytes: new Uint8Array([1, 2, 3]), soort: 'jpeg' } }], + }); + assert.equal(ingangen[0].uitsnede, false); + assert.deepEqual(await plaatjesPerPagina(bytes), [0]); + assert.match((await tekstPerPagina(bytes))[0], /#1 Page 2/); +}); + +test('tekst die Helvetica niet kent wordt een vraagteken in plaats van een fout', async () => { + const { bytes } = await bouwOpmerkingenRapport({ + ingangen: [{ pagina: 1, auteur: 'Ζωή', inhoud: 'café 中文\tok' }], + }); + const [tekst] = await tekstPerPagina(bytes); + assert.match(tekst, /café \?\? ok/); + assert.match(tekst, /Author: \?\?\?/); +}); + +test('een heel lange opmerking wordt ingekort zodat ze op een pagina past', async () => { + const lang = Array.from({ length: 400 }, (_, i) => `regel ${i}`).join('\n'); + const { bytes, paginas } = await bouwOpmerkingenRapport({ ingangen: [{ pagina: 1, inhoud: lang }] }); + assert.equal(paginas, 1); + assert.match((await tekstPerPagina(bytes))[0], /\.\.\./); +}); + +test('hulpjes: veiligeTekst, wrapTekst en pasIn', () => { + assert.equal(veiligeTekst('a\r\nb\tc€', new Set([...'abc '].map((c) => c.codePointAt(0)))), 'a\nb c?'); + assert.equal(veiligeTekst('alles', null), 'alles'); + const breedte = (s) => s.length; + assert.deepEqual(wrapTekst('een twee drie', breedte, 8), ['een twee', 'drie']); + assert.deepEqual(wrapTekst('a\n\nb', breedte, 8), ['a', '', 'b']); + assert.deepEqual(wrapTekst('abcdefghij', breedte, 4), ['abcd', 'efgh', 'ij']); + assert.deepEqual(pasIn(400, 200, 170, 130), { breedte: 170, hoogte: 85 }); + assert.deepEqual(pasIn(50, 20, 170, 130), { breedte: 50, hoogte: 20 }); + assert.deepEqual(pasIn(0, 20, 170, 130), { breedte: 0, hoogte: 0 }); +}); diff --git a/open-pdf-studio/js/i18n/locales/ar/properties.json b/open-pdf-studio/js/i18n/locales/ar/properties.json index 53db55334..2c6288c84 100644 --- a/open-pdf-studio/js/i18n/locales/ar/properties.json +++ b/open-pdf-studio/js/i18n/locales/ar/properties.json @@ -379,17 +379,44 @@ "exportComments": "تصدير التعليقات إلى ملف بيانات...", "exportSelectedComments": "تصدير التعليقات المحددة إلى ملف بيانات...", "exportListCsv": "تصدير القائمة إلى CSV...", - "summarizeComingSoon": "قريبًا", "listExported": "تم تصدير قائمة التعليقات إلى CSV", "csvPage": "الصفحة", "csvType": "النوع", "csvAuthor": "المؤلف", - "csvDate": "التاريخ", "csvStatus": "الحالة", "csvSubject": "الموضوع", "csvContents": "المحتوى", "csvColor": "اللون", "csvLayer": "الطبقة", + "csvId": "المعرّف", + "csvCreated": "تاريخ الإنشاء", + "csvModified": "تاريخ التعديل", + "csvStatusBy": "الحالة عيّنها", + "csvStatusAt": "تاريخ الحالة", + "csvReplyCount": "عدد الردود", + "csvReplies": "الردود", + "csvX": "X", + "csvY": "Y", + "csvWidth": "العرض", + "csvHeight": "الارتفاع", + "filterBar": "عوامل التصفية", + "filterAll": "(الكل)", + "filterPages": "الصفحات", + "filterDate": "التاريخ", + "filterFrom": "من", + "filterTo": "إلى", + "filterClear": "مسح عوامل التصفية", + "setStatusSelected": "تعيين حالة المحدد", + "setStatusListed": "تعيين حالة كل المدرج", + "statusChanged": "تم تغيير الحالة: {{count}}", + "summaryTitle": "ملخص التعليقات", + "summaryDocument": "المستند", + "summaryCreated": "تاريخ الإنشاء", + "summaryCount": "التعليقات", + "summaryNoText": "(لا يوجد نص للتعليق)", + "summaryBusy": "جارٍ إنشاء ملخص التعليقات...", + "summaryExported": "تم تصدير ملخص التعليقات إلى PDF", + "summaryFailed": "تعذّر إنشاء ملخص التعليقات", "schedules": "جداول الكميات", "addPage": "إضافة صفحة", "pageLabels": "تسميات الصفحات (منشئ الأسماء)", diff --git a/open-pdf-studio/js/i18n/locales/bg/properties.json b/open-pdf-studio/js/i18n/locales/bg/properties.json index d33be48fd..6eedeb08a 100644 --- a/open-pdf-studio/js/i18n/locales/bg/properties.json +++ b/open-pdf-studio/js/i18n/locales/bg/properties.json @@ -379,17 +379,44 @@ "exportComments": "Експортиране на коментари във файл...", "exportSelectedComments": "Експортиране на избрани коментари във файл...", "exportListCsv": "Експортиране на списъка в CSV...", - "summarizeComingSoon": "Очаквайте скоро", "listExported": "Списъкът с коментари е експортиран в CSV", "csvPage": "Страница", "csvType": "Тип", "csvAuthor": "Автор", - "csvDate": "Дата", "csvStatus": "Състояние", "csvSubject": "Тема", "csvContents": "Съдържание", "csvColor": "Цвят", "csvLayer": "Слой", + "csvId": "ИД", + "csvCreated": "Създадено", + "csvModified": "Променено", + "csvStatusBy": "Статус зададен от", + "csvStatusAt": "Дата на статуса", + "csvReplyCount": "Брой отговори", + "csvReplies": "Отговори", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Ширина", + "csvHeight": "Височина", + "filterBar": "Филтри", + "filterAll": "(Всички)", + "filterPages": "Страници", + "filterDate": "Дата", + "filterFrom": "От", + "filterTo": "До", + "filterClear": "Изчисти филтрите", + "setStatusSelected": "Задай статус на избраните", + "setStatusListed": "Задай статус на всички показани", + "statusChanged": "Статусът е променен: {{count}}", + "summaryTitle": "Обобщение на коментарите", + "summaryDocument": "Документ", + "summaryCreated": "Създадено", + "summaryCount": "Коментари", + "summaryNoText": "(няма текст на коментара)", + "summaryBusy": "Създаване на обобщение на коментарите...", + "summaryExported": "Обобщението на коментарите е експортирано в PDF", + "summaryFailed": "Обобщението на коментарите не можа да бъде създадено", "schedules": "Спецификации", "addPage": "Добавяне на страница", "pageLabels": "Етикети на страници (създаване на имена)", diff --git a/open-pdf-studio/js/i18n/locales/bn/properties.json b/open-pdf-studio/js/i18n/locales/bn/properties.json index a51d38ea5..0db40a672 100644 --- a/open-pdf-studio/js/i18n/locales/bn/properties.json +++ b/open-pdf-studio/js/i18n/locales/bn/properties.json @@ -379,17 +379,44 @@ "exportComments": "মন্তব্য ডেটা ফাইলে রপ্তানি...", "exportSelectedComments": "নির্বাচিত মন্তব্য ডেটা ফাইলে রপ্তানি...", "exportListCsv": "তালিকা CSV-তে রপ্তানি করুন...", - "summarizeComingSoon": "শীঘ্রই আসছে", "listExported": "মন্তব্যের তালিকা CSV-তে রপ্তানি হয়েছে", "csvPage": "পৃষ্ঠা", "csvType": "ধরন", "csvAuthor": "লেখক", - "csvDate": "তারিখ", "csvStatus": "অবস্থা", "csvSubject": "বিষয়", "csvContents": "বিষয়বস্তু", "csvColor": "রং", "csvLayer": "স্তর", + "csvId": "আইডি", + "csvCreated": "তৈরি", + "csvModified": "পরিবর্তিত", + "csvStatusBy": "স্ট্যাটাস নির্ধারণকারী", + "csvStatusAt": "স্ট্যাটাসের তারিখ", + "csvReplyCount": "উত্তরের সংখ্যা", + "csvReplies": "উত্তর", + "csvX": "X", + "csvY": "Y", + "csvWidth": "প্রস্থ", + "csvHeight": "উচ্চতা", + "filterBar": "ফিল্টার", + "filterAll": "(সব)", + "filterPages": "পৃষ্ঠা", + "filterDate": "তারিখ", + "filterFrom": "থেকে", + "filterTo": "পর্যন্ত", + "filterClear": "ফিল্টার মুছুন", + "setStatusSelected": "নির্বাচিতগুলোর স্ট্যাটাস নির্ধারণ করুন", + "setStatusListed": "তালিকার সবগুলোর স্ট্যাটাস নির্ধারণ করুন", + "statusChanged": "স্ট্যাটাস পরিবর্তিত: {{count}}", + "summaryTitle": "মন্তব্যের সারাংশ", + "summaryDocument": "নথি", + "summaryCreated": "তৈরি", + "summaryCount": "মন্তব্য", + "summaryNoText": "(কোনো মন্তব্যের লেখা নেই)", + "summaryBusy": "মন্তব্যের সারাংশ তৈরি হচ্ছে...", + "summaryExported": "মন্তব্যের সারাংশ PDF-এ রপ্তানি হয়েছে", + "summaryFailed": "মন্তব্যের সারাংশ তৈরি করা যায়নি", "schedules": "শিডিউল", "addPage": "পৃষ্ঠা যোগ করুন", "pageLabels": "পৃষ্ঠা লেবেল (নাম নির্মাতা)", diff --git a/open-pdf-studio/js/i18n/locales/ca/properties.json b/open-pdf-studio/js/i18n/locales/ca/properties.json index d06aee628..baa671242 100644 --- a/open-pdf-studio/js/i18n/locales/ca/properties.json +++ b/open-pdf-studio/js/i18n/locales/ca/properties.json @@ -375,17 +375,44 @@ "exportComments": "Exporta els comentaris a un fitxer de dades...", "exportSelectedComments": "Exporta els comentaris seleccionats a un fitxer de dades...", "exportListCsv": "Exporta la llista a CSV...", - "summarizeComingSoon": "Properament", "listExported": "Llista de comentaris exportada a CSV", "csvPage": "Pàgina", "csvType": "Tipus", "csvAuthor": "Autor", - "csvDate": "Data", "csvStatus": "Estat", "csvSubject": "Assumpte", "csvContents": "Contingut", "csvColor": "Color", "csvLayer": "Capa", + "csvId": "ID", + "csvCreated": "Creat", + "csvModified": "Modificat", + "csvStatusBy": "Estat establert per", + "csvStatusAt": "Data de l'estat", + "csvReplyCount": "Nombre de respostes", + "csvReplies": "Respostes", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Amplada", + "csvHeight": "Alçada", + "filterBar": "Filtres", + "filterAll": "(Tots)", + "filterPages": "Pàgines", + "filterDate": "Data", + "filterFrom": "Des de", + "filterTo": "Fins a", + "filterClear": "Esborra els filtres", + "setStatusSelected": "Estableix l'estat de la selecció", + "setStatusListed": "Estableix l'estat de tots els llistats", + "statusChanged": "Estat canviat: {{count}}", + "summaryTitle": "Resum de comentaris", + "summaryDocument": "Document", + "summaryCreated": "Creat", + "summaryCount": "Comentaris", + "summaryNoText": "(sense text de comentari)", + "summaryBusy": "S'està creant el resum de comentaris...", + "summaryExported": "Resum de comentaris exportat a PDF", + "summaryFailed": "No s'ha pogut crear el resum de comentaris", "sortByPage": "Pàgina", "sortByType": "Tipus", "sortByAuthor": "Autor", diff --git a/open-pdf-studio/js/i18n/locales/cs/properties.json b/open-pdf-studio/js/i18n/locales/cs/properties.json index f732bfb6b..0a53ae706 100644 --- a/open-pdf-studio/js/i18n/locales/cs/properties.json +++ b/open-pdf-studio/js/i18n/locales/cs/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportovat komentáře do datového souboru...", "exportSelectedComments": "Exportovat vybrané komentáře do datového souboru...", "exportListCsv": "Exportovat seznam do CSV...", - "summarizeComingSoon": "Již brzy", "listExported": "Seznam komentářů byl exportován do CSV", "csvPage": "Stránka", "csvType": "Typ", "csvAuthor": "Autor", - "csvDate": "Datum", "csvStatus": "Stav", "csvSubject": "Předmět", "csvContents": "Obsah", "csvColor": "Barva", "csvLayer": "Vrstva", + "csvId": "ID", + "csvCreated": "Vytvořeno", + "csvModified": "Změněno", + "csvStatusBy": "Stav nastavil", + "csvStatusAt": "Datum stavu", + "csvReplyCount": "Počet odpovědí", + "csvReplies": "Odpovědi", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Šířka", + "csvHeight": "Výška", + "filterBar": "Filtry", + "filterAll": "(Vše)", + "filterPages": "Stránky", + "filterDate": "Datum", + "filterFrom": "Od", + "filterTo": "Do", + "filterClear": "Vymazat filtry", + "setStatusSelected": "Nastavit stav vybraných", + "setStatusListed": "Nastavit stav všech zobrazených", + "statusChanged": "Stav změněn: {{count}}", + "summaryTitle": "Souhrn komentářů", + "summaryDocument": "Dokument", + "summaryCreated": "Vytvořeno", + "summaryCount": "Komentáře", + "summaryNoText": "(bez textu komentáře)", + "summaryBusy": "Vytváření souhrnu komentářů...", + "summaryExported": "Souhrn komentářů exportován do PDF", + "summaryFailed": "Souhrn komentářů se nepodařilo vytvořit", "schedules": "Výkazy", "addPage": "Přidat stránku", "pageLabels": "Popisky stránek (tvorba názvů)", diff --git a/open-pdf-studio/js/i18n/locales/da/properties.json b/open-pdf-studio/js/i18n/locales/da/properties.json index 0c4e5852d..2a845fafa 100644 --- a/open-pdf-studio/js/i18n/locales/da/properties.json +++ b/open-pdf-studio/js/i18n/locales/da/properties.json @@ -379,17 +379,44 @@ "exportComments": "Eksportér kommentarer til datafil...", "exportSelectedComments": "Eksportér valgte kommentarer til datafil...", "exportListCsv": "Eksportér listen til CSV...", - "summarizeComingSoon": "Kommer snart", "listExported": "Kommentarlisten er eksporteret til CSV", "csvPage": "Side", "csvType": "Type", "csvAuthor": "Forfatter", - "csvDate": "Dato", "csvStatus": "Status", "csvSubject": "Emne", "csvContents": "Indhold", "csvColor": "Farve", "csvLayer": "Lag", + "csvId": "ID", + "csvCreated": "Oprettet", + "csvModified": "Ændret", + "csvStatusBy": "Status angivet af", + "csvStatusAt": "Statusdato", + "csvReplyCount": "Antal svar", + "csvReplies": "Svar", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Bredde", + "csvHeight": "Højde", + "filterBar": "Filtre", + "filterAll": "(Alle)", + "filterPages": "Sider", + "filterDate": "Dato", + "filterFrom": "Fra", + "filterTo": "Til", + "filterClear": "Ryd filtre", + "setStatusSelected": "Angiv status for markerede", + "setStatusListed": "Angiv status for alle viste", + "statusChanged": "Status ændret: {{count}}", + "summaryTitle": "Kommentaroversigt", + "summaryDocument": "Dokument", + "summaryCreated": "Oprettet", + "summaryCount": "Kommentarer", + "summaryNoText": "(ingen kommentartekst)", + "summaryBusy": "Opretter kommentaroversigt...", + "summaryExported": "Kommentaroversigt eksporteret til PDF", + "summaryFailed": "Kommentaroversigten kunne ikke oprettes", "schedules": "Lister", "addPage": "Tilføj side", "pageLabels": "Sideetiketter (navnegenerator)", diff --git a/open-pdf-studio/js/i18n/locales/de/properties.json b/open-pdf-studio/js/i18n/locales/de/properties.json index 1ea736133..073191cc1 100644 --- a/open-pdf-studio/js/i18n/locales/de/properties.json +++ b/open-pdf-studio/js/i18n/locales/de/properties.json @@ -379,17 +379,44 @@ "exportComments": "Kommentare in Datendatei exportieren...", "exportSelectedComments": "Ausgewählte Kommentare in Datendatei exportieren...", "exportListCsv": "Liste als CSV exportieren...", - "summarizeComingSoon": "Demnächst verfügbar", "listExported": "Kommentarliste als CSV exportiert", "csvPage": "Seite", "csvType": "Typ", "csvAuthor": "Autor", - "csvDate": "Datum", "csvStatus": "Status", "csvSubject": "Betreff", "csvContents": "Inhalt", "csvColor": "Farbe", "csvLayer": "Ebene", + "csvId": "ID", + "csvCreated": "Erstellt", + "csvModified": "Geändert", + "csvStatusBy": "Status gesetzt von", + "csvStatusAt": "Statusdatum", + "csvReplyCount": "Anzahl Antworten", + "csvReplies": "Antworten", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Breite", + "csvHeight": "Höhe", + "filterBar": "Filter", + "filterAll": "(Alle)", + "filterPages": "Seiten", + "filterDate": "Datum", + "filterFrom": "Von", + "filterTo": "Bis", + "filterClear": "Filter löschen", + "setStatusSelected": "Status der Auswahl festlegen", + "setStatusListed": "Status aller angezeigten festlegen", + "statusChanged": "Status geändert: {{count}}", + "summaryTitle": "Kommentarübersicht", + "summaryDocument": "Dokument", + "summaryCreated": "Erstellt", + "summaryCount": "Kommentare", + "summaryNoText": "(kein Kommentartext)", + "summaryBusy": "Kommentarübersicht wird erstellt...", + "summaryExported": "Kommentarübersicht als PDF exportiert", + "summaryFailed": "Die Kommentarübersicht konnte nicht erstellt werden", "schedules": "Listen", "addPage": "Seite hinzufügen", "pageLabels": "Seitenbeschriftungen (Namensgenerator)", diff --git a/open-pdf-studio/js/i18n/locales/el/properties.json b/open-pdf-studio/js/i18n/locales/el/properties.json index 37b6ff23e..e9e8e6700 100644 --- a/open-pdf-studio/js/i18n/locales/el/properties.json +++ b/open-pdf-studio/js/i18n/locales/el/properties.json @@ -379,17 +379,44 @@ "exportComments": "Εξαγωγή σχολίων σε αρχείο...", "exportSelectedComments": "Εξαγωγή επιλεγμένων σχολίων σε αρχείο...", "exportListCsv": "Εξαγωγή λίστας σε CSV...", - "summarizeComingSoon": "Σύντομα διαθέσιμο", "listExported": "Η λίστα σχολίων εξήχθη σε CSV", "csvPage": "Σελίδα", "csvType": "Τύπος", "csvAuthor": "Συντάκτης", - "csvDate": "Ημερομηνία", "csvStatus": "Κατάσταση", "csvSubject": "Θέμα", "csvContents": "Περιεχόμενο", "csvColor": "Χρώμα", "csvLayer": "Επίπεδο", + "csvId": "Αναγνωριστικό", + "csvCreated": "Δημιουργήθηκε", + "csvModified": "Τροποποιήθηκε", + "csvStatusBy": "Κατάσταση από", + "csvStatusAt": "Ημερομηνία κατάστασης", + "csvReplyCount": "Αριθμός απαντήσεων", + "csvReplies": "Απαντήσεις", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Πλάτος", + "csvHeight": "Ύψος", + "filterBar": "Φίλτρα", + "filterAll": "(Όλα)", + "filterPages": "Σελίδες", + "filterDate": "Ημερομηνία", + "filterFrom": "Από", + "filterTo": "Έως", + "filterClear": "Απαλοιφή φίλτρων", + "setStatusSelected": "Ορισμός κατάστασης επιλεγμένων", + "setStatusListed": "Ορισμός κατάστασης όλων στη λίστα", + "statusChanged": "Η κατάσταση άλλαξε: {{count}}", + "summaryTitle": "Σύνοψη σχολίων", + "summaryDocument": "Έγγραφο", + "summaryCreated": "Δημιουργήθηκε", + "summaryCount": "Σχόλια", + "summaryNoText": "(χωρίς κείμενο σχολίου)", + "summaryBusy": "Δημιουργία σύνοψης σχολίων...", + "summaryExported": "Η σύνοψη σχολίων εξήχθη σε PDF", + "summaryFailed": "Δεν ήταν δυνατή η δημιουργία της σύνοψης σχολίων", "schedules": "Πίνακες ποσοτήτων", "addPage": "Προσθήκη σελίδας", "pageLabels": "Ετικέτες σελίδων (δημιουργία ονομάτων)", diff --git a/open-pdf-studio/js/i18n/locales/en/properties.json b/open-pdf-studio/js/i18n/locales/en/properties.json index c91ac238b..10f285d9d 100644 --- a/open-pdf-studio/js/i18n/locales/en/properties.json +++ b/open-pdf-studio/js/i18n/locales/en/properties.json @@ -407,17 +407,44 @@ "exportComments": "Export Comments to Data File...", "exportSelectedComments": "Export Selected Comments to Data File...", "exportListCsv": "Export List to CSV...", - "summarizeComingSoon": "Coming soon", "listExported": "Comment list exported to CSV", "csvPage": "Page", "csvType": "Type", "csvAuthor": "Author", - "csvDate": "Date", "csvStatus": "Status", "csvSubject": "Subject", "csvContents": "Contents", "csvColor": "Color", "csvLayer": "Layer", + "csvId": "ID", + "csvCreated": "Created", + "csvModified": "Modified", + "csvStatusBy": "Status Set By", + "csvStatusAt": "Status Date", + "csvReplyCount": "Number of Replies", + "csvReplies": "Replies", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Width", + "csvHeight": "Height", + "filterBar": "Filters", + "filterAll": "(All)", + "filterPages": "Pages", + "filterDate": "Date", + "filterFrom": "From", + "filterTo": "To", + "filterClear": "Clear Filters", + "setStatusSelected": "Set Status of Selected", + "setStatusListed": "Set Status of All Listed", + "statusChanged": "Status changed: {{count}}", + "summaryTitle": "Comment Summary", + "summaryDocument": "Document", + "summaryCreated": "Created", + "summaryCount": "Comments", + "summaryNoText": "(no comment text)", + "summaryBusy": "Creating comment summary...", + "summaryExported": "Comment summary exported to PDF", + "summaryFailed": "Could not create the comment summary", "filterLabel": "Filter", "expandAll": "Expand All", "collapseAll": "Collapse All", diff --git a/open-pdf-studio/js/i18n/locales/es/properties.json b/open-pdf-studio/js/i18n/locales/es/properties.json index 8fc345c95..bacd7adf0 100644 --- a/open-pdf-studio/js/i18n/locales/es/properties.json +++ b/open-pdf-studio/js/i18n/locales/es/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportar comentarios a archivo de datos...", "exportSelectedComments": "Exportar comentarios seleccionados a archivo de datos...", "exportListCsv": "Exportar lista a CSV...", - "summarizeComingSoon": "Próximamente", "listExported": "Lista de comentarios exportada a CSV", "csvPage": "Página", "csvType": "Tipo", "csvAuthor": "Autor", - "csvDate": "Fecha", "csvStatus": "Estado", "csvSubject": "Asunto", "csvContents": "Contenido", "csvColor": "Color", "csvLayer": "Capa", + "csvId": "ID", + "csvCreated": "Creado", + "csvModified": "Modificado", + "csvStatusBy": "Estado establecido por", + "csvStatusAt": "Fecha del estado", + "csvReplyCount": "Número de respuestas", + "csvReplies": "Respuestas", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Ancho", + "csvHeight": "Alto", + "filterBar": "Filtros", + "filterAll": "(Todos)", + "filterPages": "Páginas", + "filterDate": "Fecha", + "filterFrom": "Desde", + "filterTo": "Hasta", + "filterClear": "Borrar filtros", + "setStatusSelected": "Establecer estado de la selección", + "setStatusListed": "Establecer estado de todos los listados", + "statusChanged": "Estado cambiado: {{count}}", + "summaryTitle": "Resumen de comentarios", + "summaryDocument": "Documento", + "summaryCreated": "Creado", + "summaryCount": "Comentarios", + "summaryNoText": "(sin texto de comentario)", + "summaryBusy": "Creando resumen de comentarios...", + "summaryExported": "Resumen de comentarios exportado a PDF", + "summaryFailed": "No se pudo crear el resumen de comentarios", "schedules": "Listados", "addPage": "Añadir página", "pageLabels": "Etiquetas de página (generador de nombres)", diff --git a/open-pdf-studio/js/i18n/locales/fa/properties.json b/open-pdf-studio/js/i18n/locales/fa/properties.json index aab8fed64..a54e63082 100644 --- a/open-pdf-studio/js/i18n/locales/fa/properties.json +++ b/open-pdf-studio/js/i18n/locales/fa/properties.json @@ -379,17 +379,44 @@ "exportComments": "صادر کردن نظرات به فایل داده...", "exportSelectedComments": "صادر کردن نظرات انتخاب‌شده به فایل داده...", "exportListCsv": "صدور فهرست به CSV...", - "summarizeComingSoon": "به‌زودی", "listExported": "فهرست نظرها به CSV صادر شد", "csvPage": "صفحه", "csvType": "نوع", "csvAuthor": "نویسنده", - "csvDate": "تاریخ", "csvStatus": "وضعیت", "csvSubject": "موضوع", "csvContents": "محتوا", "csvColor": "رنگ", "csvLayer": "لایه", + "csvId": "شناسه", + "csvCreated": "ایجاد شده", + "csvModified": "تغییر یافته", + "csvStatusBy": "وضعیت تنظیم شده توسط", + "csvStatusAt": "تاریخ وضعیت", + "csvReplyCount": "تعداد پاسخ‌ها", + "csvReplies": "پاسخ‌ها", + "csvX": "X", + "csvY": "Y", + "csvWidth": "عرض", + "csvHeight": "ارتفاع", + "filterBar": "فیلترها", + "filterAll": "(همه)", + "filterPages": "صفحه‌ها", + "filterDate": "تاریخ", + "filterFrom": "از", + "filterTo": "تا", + "filterClear": "پاک کردن فیلترها", + "setStatusSelected": "تنظیم وضعیت موارد انتخاب‌شده", + "setStatusListed": "تنظیم وضعیت همه موارد فهرست‌شده", + "statusChanged": "وضعیت تغییر کرد: {{count}}", + "summaryTitle": "خلاصه نظرها", + "summaryDocument": "سند", + "summaryCreated": "ایجاد شده", + "summaryCount": "نظرها", + "summaryNoText": "(بدون متن نظر)", + "summaryBusy": "در حال ایجاد خلاصه نظرها...", + "summaryExported": "خلاصه نظرها به PDF صادر شد", + "summaryFailed": "ایجاد خلاصه نظرها ممکن نشد", "schedules": "جدول‌های مقادیر", "addPage": "افزودن صفحه", "pageLabels": "برچسب‌های صفحه (سازنده نام)", diff --git a/open-pdf-studio/js/i18n/locales/fi/properties.json b/open-pdf-studio/js/i18n/locales/fi/properties.json index 6f51f7087..05de1f22f 100644 --- a/open-pdf-studio/js/i18n/locales/fi/properties.json +++ b/open-pdf-studio/js/i18n/locales/fi/properties.json @@ -379,17 +379,44 @@ "exportComments": "Vie kommentit datatiedostoon...", "exportSelectedComments": "Vie valitut kommentit datatiedostoon...", "exportListCsv": "Vie luettelo CSV-tiedostoon...", - "summarizeComingSoon": "Tulossa pian", "listExported": "Kommenttiluettelo viety CSV-tiedostoon", "csvPage": "Sivu", "csvType": "Tyyppi", "csvAuthor": "Tekijä", - "csvDate": "Päivämäärä", "csvStatus": "Tila", "csvSubject": "Aihe", "csvContents": "Sisältö", "csvColor": "Väri", "csvLayer": "Taso", + "csvId": "Tunnus", + "csvCreated": "Luotu", + "csvModified": "Muokattu", + "csvStatusBy": "Tilan asettaja", + "csvStatusAt": "Tilan päivämäärä", + "csvReplyCount": "Vastausten määrä", + "csvReplies": "Vastaukset", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Leveys", + "csvHeight": "Korkeus", + "filterBar": "Suodattimet", + "filterAll": "(Kaikki)", + "filterPages": "Sivut", + "filterDate": "Päivämäärä", + "filterFrom": "Alkaen", + "filterTo": "Asti", + "filterClear": "Tyhjennä suodattimet", + "setStatusSelected": "Aseta valittujen tila", + "setStatusListed": "Aseta kaikkien listattujen tila", + "statusChanged": "Tila muutettu: {{count}}", + "summaryTitle": "Kommenttiyhteenveto", + "summaryDocument": "Asiakirja", + "summaryCreated": "Luotu", + "summaryCount": "Kommentit", + "summaryNoText": "(ei kommenttitekstiä)", + "summaryBusy": "Luodaan kommenttiyhteenvetoa...", + "summaryExported": "Kommenttiyhteenveto viety PDF-tiedostoon", + "summaryFailed": "Kommenttiyhteenvetoa ei voitu luoda", "schedules": "Luettelot", "addPage": "Lisää sivu", "pageLabels": "Sivujen nimiöt (nimien muodostus)", diff --git a/open-pdf-studio/js/i18n/locales/fr/properties.json b/open-pdf-studio/js/i18n/locales/fr/properties.json index 44536d68a..2a0867572 100644 --- a/open-pdf-studio/js/i18n/locales/fr/properties.json +++ b/open-pdf-studio/js/i18n/locales/fr/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exporter les commentaires vers un fichier...", "exportSelectedComments": "Exporter les commentaires sélectionnés vers un fichier...", "exportListCsv": "Exporter la liste en CSV...", - "summarizeComingSoon": "Bientôt disponible", "listExported": "Liste des commentaires exportée en CSV", "csvPage": "Page", "csvType": "Type", "csvAuthor": "Auteur", - "csvDate": "Date", "csvStatus": "Statut", "csvSubject": "Objet", "csvContents": "Contenu", "csvColor": "Couleur", "csvLayer": "Calque", + "csvId": "ID", + "csvCreated": "Créé", + "csvModified": "Modifié", + "csvStatusBy": "Statut défini par", + "csvStatusAt": "Date du statut", + "csvReplyCount": "Nombre de réponses", + "csvReplies": "Réponses", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Largeur", + "csvHeight": "Hauteur", + "filterBar": "Filtres", + "filterAll": "(Tous)", + "filterPages": "Pages", + "filterDate": "Date", + "filterFrom": "Du", + "filterTo": "Au", + "filterClear": "Effacer les filtres", + "setStatusSelected": "Définir le statut de la sélection", + "setStatusListed": "Définir le statut de tous les éléments listés", + "statusChanged": "Statut modifié : {{count}}", + "summaryTitle": "Résumé des commentaires", + "summaryDocument": "Document", + "summaryCreated": "Créé le", + "summaryCount": "Commentaires", + "summaryNoText": "(aucun texte de commentaire)", + "summaryBusy": "Création du résumé des commentaires...", + "summaryExported": "Résumé des commentaires exporté en PDF", + "summaryFailed": "Impossible de créer le résumé des commentaires", "schedules": "Nomenclatures", "addPage": "Ajouter une page", "pageLabels": "Étiquettes de page (générateur de noms)", diff --git a/open-pdf-studio/js/i18n/locales/he/properties.json b/open-pdf-studio/js/i18n/locales/he/properties.json index cf55f3a92..9daf486fa 100644 --- a/open-pdf-studio/js/i18n/locales/he/properties.json +++ b/open-pdf-studio/js/i18n/locales/he/properties.json @@ -379,17 +379,44 @@ "exportComments": "ייצא הערות לקובץ נתונים...", "exportSelectedComments": "ייצא הערות נבחרות לקובץ נתונים...", "exportListCsv": "ייצוא הרשימה ל-CSV...", - "summarizeComingSoon": "בקרוב", "listExported": "רשימת ההערות יוצאה ל-CSV", "csvPage": "עמוד", "csvType": "סוג", "csvAuthor": "מחבר", - "csvDate": "תאריך", "csvStatus": "מצב", "csvSubject": "נושא", "csvContents": "תוכן", "csvColor": "צבע", "csvLayer": "שכבה", + "csvId": "מזהה", + "csvCreated": "נוצר", + "csvModified": "שונה", + "csvStatusBy": "הסטטוס נקבע על ידי", + "csvStatusAt": "תאריך הסטטוס", + "csvReplyCount": "מספר תגובות", + "csvReplies": "תגובות", + "csvX": "X", + "csvY": "Y", + "csvWidth": "רוחב", + "csvHeight": "גובה", + "filterBar": "מסננים", + "filterAll": "(הכול)", + "filterPages": "עמודים", + "filterDate": "תאריך", + "filterFrom": "מ-", + "filterTo": "עד", + "filterClear": "ניקוי מסננים", + "setStatusSelected": "הגדרת סטטוס לנבחרים", + "setStatusListed": "הגדרת סטטוס לכל המופיעים ברשימה", + "statusChanged": "הסטטוס שונה: {{count}}", + "summaryTitle": "סיכום הערות", + "summaryDocument": "מסמך", + "summaryCreated": "נוצר", + "summaryCount": "הערות", + "summaryNoText": "(אין טקסט הערה)", + "summaryBusy": "יוצר סיכום הערות...", + "summaryExported": "סיכום ההערות יוצא ל-PDF", + "summaryFailed": "לא ניתן ליצור את סיכום ההערות", "schedules": "טבלאות כמויות", "addPage": "הוספת עמוד", "pageLabels": "תוויות עמודים (בונה שמות)", diff --git a/open-pdf-studio/js/i18n/locales/hi/properties.json b/open-pdf-studio/js/i18n/locales/hi/properties.json index 6f45a9df5..6d8056a2c 100644 --- a/open-pdf-studio/js/i18n/locales/hi/properties.json +++ b/open-pdf-studio/js/i18n/locales/hi/properties.json @@ -379,17 +379,44 @@ "exportComments": "टिप्पणियाँ डेटा फ़ाइल में निर्यात करें...", "exportSelectedComments": "चयनित टिप्पणियाँ डेटा फ़ाइल में निर्यात करें...", "exportListCsv": "सूची को CSV में निर्यात करें...", - "summarizeComingSoon": "जल्द आ रहा है", "listExported": "टिप्पणी सूची CSV में निर्यात की गई", "csvPage": "पृष्ठ", "csvType": "प्रकार", "csvAuthor": "लेखक", - "csvDate": "दिनांक", "csvStatus": "स्थिति", "csvSubject": "विषय", "csvContents": "सामग्री", "csvColor": "रंग", "csvLayer": "परत", + "csvId": "आईडी", + "csvCreated": "बनाया गया", + "csvModified": "संशोधित", + "csvStatusBy": "स्थिति सेट करने वाला", + "csvStatusAt": "स्थिति की तिथि", + "csvReplyCount": "उत्तरों की संख्या", + "csvReplies": "उत्तर", + "csvX": "X", + "csvY": "Y", + "csvWidth": "चौड़ाई", + "csvHeight": "ऊँचाई", + "filterBar": "फ़िल्टर", + "filterAll": "(सभी)", + "filterPages": "पृष्ठ", + "filterDate": "तिथि", + "filterFrom": "से", + "filterTo": "तक", + "filterClear": "फ़िल्टर साफ़ करें", + "setStatusSelected": "चयनित की स्थिति सेट करें", + "setStatusListed": "सूची में सभी की स्थिति सेट करें", + "statusChanged": "स्थिति बदली गई: {{count}}", + "summaryTitle": "टिप्पणी सारांश", + "summaryDocument": "दस्तावेज़", + "summaryCreated": "बनाया गया", + "summaryCount": "टिप्पणियाँ", + "summaryNoText": "(कोई टिप्पणी पाठ नहीं)", + "summaryBusy": "टिप्पणी सारांश बनाया जा रहा है...", + "summaryExported": "टिप्पणी सारांश PDF में निर्यात किया गया", + "summaryFailed": "टिप्पणी सारांश नहीं बनाया जा सका", "schedules": "शेड्यूल", "addPage": "पृष्ठ जोड़ें", "pageLabels": "पृष्ठ लेबल (नाम निर्माता)", diff --git a/open-pdf-studio/js/i18n/locales/hr/properties.json b/open-pdf-studio/js/i18n/locales/hr/properties.json index bf7a76ea6..58d30450b 100644 --- a/open-pdf-studio/js/i18n/locales/hr/properties.json +++ b/open-pdf-studio/js/i18n/locales/hr/properties.json @@ -379,17 +379,44 @@ "exportComments": "Izvezi komentare u podatkovnu datoteku...", "exportSelectedComments": "Izvezi odabrane komentare u podatkovnu datoteku...", "exportListCsv": "Izvezi popis u CSV...", - "summarizeComingSoon": "Uskoro", "listExported": "Popis komentara izvezen je u CSV", "csvPage": "Stranica", "csvType": "Vrsta", "csvAuthor": "Autor", - "csvDate": "Datum", "csvStatus": "Status", "csvSubject": "Predmet", "csvContents": "Sadržaj", "csvColor": "Boja", "csvLayer": "Sloj", + "csvId": "ID", + "csvCreated": "Stvoreno", + "csvModified": "Izmijenjeno", + "csvStatusBy": "Status postavio", + "csvStatusAt": "Datum statusa", + "csvReplyCount": "Broj odgovora", + "csvReplies": "Odgovori", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Širina", + "csvHeight": "Visina", + "filterBar": "Filtri", + "filterAll": "(Sve)", + "filterPages": "Stranice", + "filterDate": "Datum", + "filterFrom": "Od", + "filterTo": "Do", + "filterClear": "Očisti filtre", + "setStatusSelected": "Postavi status odabranih", + "setStatusListed": "Postavi status svih prikazanih", + "statusChanged": "Status promijenjen: {{count}}", + "summaryTitle": "Sažetak komentara", + "summaryDocument": "Dokument", + "summaryCreated": "Stvoreno", + "summaryCount": "Komentari", + "summaryNoText": "(nema teksta komentara)", + "summaryBusy": "Izrada sažetka komentara...", + "summaryExported": "Sažetak komentara izvezen u PDF", + "summaryFailed": "Sažetak komentara nije moguće izraditi", "schedules": "Iskazi", "addPage": "Dodaj stranicu", "pageLabels": "Oznake stranica (izrada naziva)", diff --git a/open-pdf-studio/js/i18n/locales/hu/properties.json b/open-pdf-studio/js/i18n/locales/hu/properties.json index 8867f3f90..351b9cc46 100644 --- a/open-pdf-studio/js/i18n/locales/hu/properties.json +++ b/open-pdf-studio/js/i18n/locales/hu/properties.json @@ -379,17 +379,44 @@ "exportComments": "Megjegyzések exportálása adatfájlba...", "exportSelectedComments": "Kijelölt megjegyzések exportálása adatfájlba...", "exportListCsv": "Lista exportálása CSV-be...", - "summarizeComingSoon": "Hamarosan", "listExported": "A megjegyzéslista exportálva CSV-be", "csvPage": "Oldal", "csvType": "Típus", "csvAuthor": "Szerző", - "csvDate": "Dátum", "csvStatus": "Állapot", "csvSubject": "Tárgy", "csvContents": "Tartalom", "csvColor": "Szín", "csvLayer": "Réteg", + "csvId": "Azonosító", + "csvCreated": "Létrehozva", + "csvModified": "Módosítva", + "csvStatusBy": "Állapotot beállította", + "csvStatusAt": "Állapot dátuma", + "csvReplyCount": "Válaszok száma", + "csvReplies": "Válaszok", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Szélesség", + "csvHeight": "Magasság", + "filterBar": "Szűrők", + "filterAll": "(Összes)", + "filterPages": "Oldalak", + "filterDate": "Dátum", + "filterFrom": "Ettől", + "filterTo": "Eddig", + "filterClear": "Szűrők törlése", + "setStatusSelected": "Kijelöltek állapotának beállítása", + "setStatusListed": "Az összes listázott állapotának beállítása", + "statusChanged": "Állapot módosítva: {{count}}", + "summaryTitle": "Megjegyzések összesítése", + "summaryDocument": "Dokumentum", + "summaryCreated": "Létrehozva", + "summaryCount": "Megjegyzések", + "summaryNoText": "(nincs megjegyzésszöveg)", + "summaryBusy": "Megjegyzés-összesítő készítése...", + "summaryExported": "A megjegyzés-összesítő PDF-be exportálva", + "summaryFailed": "A megjegyzés-összesítőt nem sikerült elkészíteni", "schedules": "Kimutatások", "addPage": "Oldal hozzáadása", "pageLabels": "Oldalcímkék (névkészítő)", diff --git a/open-pdf-studio/js/i18n/locales/id/properties.json b/open-pdf-studio/js/i18n/locales/id/properties.json index ba5270134..4928938a2 100644 --- a/open-pdf-studio/js/i18n/locales/id/properties.json +++ b/open-pdf-studio/js/i18n/locales/id/properties.json @@ -379,17 +379,44 @@ "exportComments": "Ekspor komentar ke file data...", "exportSelectedComments": "Ekspor komentar terpilih ke file data...", "exportListCsv": "Ekspor daftar ke CSV...", - "summarizeComingSoon": "Segera hadir", "listExported": "Daftar komentar diekspor ke CSV", "csvPage": "Halaman", "csvType": "Jenis", "csvAuthor": "Penulis", - "csvDate": "Tanggal", "csvStatus": "Status", "csvSubject": "Subjek", "csvContents": "Isi", "csvColor": "Warna", "csvLayer": "Lapisan", + "csvId": "ID", + "csvCreated": "Dibuat", + "csvModified": "Diubah", + "csvStatusBy": "Status diatur oleh", + "csvStatusAt": "Tanggal status", + "csvReplyCount": "Jumlah balasan", + "csvReplies": "Balasan", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Lebar", + "csvHeight": "Tinggi", + "filterBar": "Filter", + "filterAll": "(Semua)", + "filterPages": "Halaman", + "filterDate": "Tanggal", + "filterFrom": "Dari", + "filterTo": "Sampai", + "filterClear": "Hapus filter", + "setStatusSelected": "Atur status yang dipilih", + "setStatusListed": "Atur status semua yang tercantum", + "statusChanged": "Status diubah: {{count}}", + "summaryTitle": "Ringkasan komentar", + "summaryDocument": "Dokumen", + "summaryCreated": "Dibuat", + "summaryCount": "Komentar", + "summaryNoText": "(tidak ada teks komentar)", + "summaryBusy": "Membuat ringkasan komentar...", + "summaryExported": "Ringkasan komentar diekspor ke PDF", + "summaryFailed": "Ringkasan komentar tidak dapat dibuat", "schedules": "Daftar", "addPage": "Tambah halaman", "pageLabels": "Label halaman (pembuat nama)", diff --git a/open-pdf-studio/js/i18n/locales/it/properties.json b/open-pdf-studio/js/i18n/locales/it/properties.json index f83e14803..f7e66fc9d 100644 --- a/open-pdf-studio/js/i18n/locales/it/properties.json +++ b/open-pdf-studio/js/i18n/locales/it/properties.json @@ -379,17 +379,44 @@ "exportComments": "Esporta commenti in file di dati...", "exportSelectedComments": "Esporta commenti selezionati in file di dati...", "exportListCsv": "Esporta elenco in CSV...", - "summarizeComingSoon": "Prossimamente", "listExported": "Elenco dei commenti esportato in CSV", "csvPage": "Pagina", "csvType": "Tipo", "csvAuthor": "Autore", - "csvDate": "Data", "csvStatus": "Stato", "csvSubject": "Oggetto", "csvContents": "Contenuto", "csvColor": "Colore", "csvLayer": "Livello", + "csvId": "ID", + "csvCreated": "Creato", + "csvModified": "Modificato", + "csvStatusBy": "Stato impostato da", + "csvStatusAt": "Data dello stato", + "csvReplyCount": "Numero di risposte", + "csvReplies": "Risposte", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Larghezza", + "csvHeight": "Altezza", + "filterBar": "Filtri", + "filterAll": "(Tutti)", + "filterPages": "Pagine", + "filterDate": "Data", + "filterFrom": "Da", + "filterTo": "A", + "filterClear": "Cancella filtri", + "setStatusSelected": "Imposta stato della selezione", + "setStatusListed": "Imposta stato di tutti gli elementi elencati", + "statusChanged": "Stato modificato: {{count}}", + "summaryTitle": "Riepilogo commenti", + "summaryDocument": "Documento", + "summaryCreated": "Creato", + "summaryCount": "Commenti", + "summaryNoText": "(nessun testo del commento)", + "summaryBusy": "Creazione del riepilogo commenti...", + "summaryExported": "Riepilogo commenti esportato in PDF", + "summaryFailed": "Impossibile creare il riepilogo commenti", "schedules": "Abachi", "addPage": "Aggiungi pagina", "pageLabels": "Etichette di pagina (generatore di nomi)", diff --git a/open-pdf-studio/js/i18n/locales/ja/properties.json b/open-pdf-studio/js/i18n/locales/ja/properties.json index c1146b05f..e412c1dda 100644 --- a/open-pdf-studio/js/i18n/locales/ja/properties.json +++ b/open-pdf-studio/js/i18n/locales/ja/properties.json @@ -379,17 +379,44 @@ "exportComments": "コメントをデータファイルにエクスポート...", "exportSelectedComments": "選択したコメントをデータファイルにエクスポート...", "exportListCsv": "リストを CSV にエクスポート...", - "summarizeComingSoon": "近日公開", "listExported": "コメントリストを CSV にエクスポートしました", "csvPage": "ページ", "csvType": "種類", "csvAuthor": "作成者", - "csvDate": "日付", "csvStatus": "ステータス", "csvSubject": "件名", "csvContents": "内容", "csvColor": "色", "csvLayer": "レイヤー", + "csvId": "ID", + "csvCreated": "作成日時", + "csvModified": "更新日時", + "csvStatusBy": "ステータス設定者", + "csvStatusAt": "ステータス日時", + "csvReplyCount": "返信数", + "csvReplies": "返信", + "csvX": "X", + "csvY": "Y", + "csvWidth": "幅", + "csvHeight": "高さ", + "filterBar": "フィルター", + "filterAll": "(すべて)", + "filterPages": "ページ", + "filterDate": "日付", + "filterFrom": "開始", + "filterTo": "終了", + "filterClear": "フィルターをクリア", + "setStatusSelected": "選択項目のステータスを設定", + "setStatusListed": "一覧のすべてのステータスを設定", + "statusChanged": "ステータスを変更しました: {{count}}", + "summaryTitle": "コメントの要約", + "summaryDocument": "文書", + "summaryCreated": "作成日時", + "summaryCount": "コメント", + "summaryNoText": "(コメントテキストなし)", + "summaryBusy": "コメントの要約を作成しています...", + "summaryExported": "コメントの要約を PDF にエクスポートしました", + "summaryFailed": "コメントの要約を作成できませんでした", "schedules": "集計表", "addPage": "ページを追加", "pageLabels": "ページラベル (名前ビルダー)", diff --git a/open-pdf-studio/js/i18n/locales/ko/properties.json b/open-pdf-studio/js/i18n/locales/ko/properties.json index 43b942a0f..a8fcf271e 100644 --- a/open-pdf-studio/js/i18n/locales/ko/properties.json +++ b/open-pdf-studio/js/i18n/locales/ko/properties.json @@ -379,17 +379,44 @@ "exportComments": "주석을 데이터 파일로 내보내기...", "exportSelectedComments": "선택한 주석을 데이터 파일로 내보내기...", "exportListCsv": "목록을 CSV로 내보내기...", - "summarizeComingSoon": "곧 제공 예정", "listExported": "주석 목록을 CSV로 내보냈습니다", "csvPage": "페이지", "csvType": "유형", "csvAuthor": "작성자", - "csvDate": "날짜", "csvStatus": "상태", "csvSubject": "제목", "csvContents": "내용", "csvColor": "색상", "csvLayer": "레이어", + "csvId": "ID", + "csvCreated": "만든 날짜", + "csvModified": "수정한 날짜", + "csvStatusBy": "상태 설정자", + "csvStatusAt": "상태 날짜", + "csvReplyCount": "답글 수", + "csvReplies": "답글", + "csvX": "X", + "csvY": "Y", + "csvWidth": "너비", + "csvHeight": "높이", + "filterBar": "필터", + "filterAll": "(모두)", + "filterPages": "페이지", + "filterDate": "날짜", + "filterFrom": "시작", + "filterTo": "끝", + "filterClear": "필터 지우기", + "setStatusSelected": "선택 항목의 상태 설정", + "setStatusListed": "목록의 모든 항목 상태 설정", + "statusChanged": "상태 변경됨: {{count}}", + "summaryTitle": "주석 요약", + "summaryDocument": "문서", + "summaryCreated": "만든 날짜", + "summaryCount": "주석", + "summaryNoText": "(주석 텍스트 없음)", + "summaryBusy": "주석 요약을 만드는 중...", + "summaryExported": "주석 요약을 PDF로 내보냈습니다", + "summaryFailed": "주석 요약을 만들 수 없습니다", "schedules": "일람표", "addPage": "페이지 추가", "pageLabels": "페이지 레이블(이름 작성기)", diff --git a/open-pdf-studio/js/i18n/locales/lo/properties.json b/open-pdf-studio/js/i18n/locales/lo/properties.json index fa508652a..0750abb92 100644 --- a/open-pdf-studio/js/i18n/locales/lo/properties.json +++ b/open-pdf-studio/js/i18n/locales/lo/properties.json @@ -407,17 +407,44 @@ "exportComments": "ສົ່ງອອກຄຳເຫັນໄປຍັງໄຟລ໌ຂໍ້ມູນ...", "exportSelectedComments": "ສົ່ງອອກຄຳເຫັນທີ່ເລືອກໄປຍັງໄຟລ໌ຂໍ້ມູນ...", "exportListCsv": "ສົ່ງອອກລາຍການເປັນ CSV...", - "summarizeComingSoon": "ໄວໆນີ້", "listExported": "ສົ່ງອອກລາຍການຄຳເຫັນເປັນ CSV ແລ້ວ", "csvPage": "ໜ້າ", "csvType": "ປະເພດ", "csvAuthor": "ຜູ້ຂຽນ", - "csvDate": "ວັນທີ", "csvStatus": "ສະຖານະ", "csvSubject": "ຫົວເລື່ອງ", "csvContents": "ເນື້ອຫາ", "csvColor": "ສີ", "csvLayer": "ຊັ້ນ", + "csvId": "ລະຫັດ", + "csvCreated": "ສ້າງເມື່ອ", + "csvModified": "ແກ້ໄຂເມື່ອ", + "csvStatusBy": "ສະຖານະຕັ້ງໂດຍ", + "csvStatusAt": "ວັນທີຂອງສະຖານະ", + "csvReplyCount": "ຈຳນວນການຕອບກັບ", + "csvReplies": "ການຕອບກັບ", + "csvX": "X", + "csvY": "Y", + "csvWidth": "ຄວາມກວ້າງ", + "csvHeight": "ຄວາມສູງ", + "filterBar": "ຕົວກອງ", + "filterAll": "(ທັງໝົດ)", + "filterPages": "ໜ້າ", + "filterDate": "ວັນທີ", + "filterFrom": "ຈາກ", + "filterTo": "ຫາ", + "filterClear": "ລ້າງຕົວກອງ", + "setStatusSelected": "ຕັ້ງສະຖານະຂອງລາຍການທີ່ເລືອກ", + "setStatusListed": "ຕັ້ງສະຖານະຂອງທຸກລາຍການໃນລາຍຊື່", + "statusChanged": "ປ່ຽນສະຖານະແລ້ວ: {{count}}", + "summaryTitle": "ສະຫຼຸບຄຳເຫັນ", + "summaryDocument": "ເອກະສານ", + "summaryCreated": "ສ້າງເມື່ອ", + "summaryCount": "ຄຳເຫັນ", + "summaryNoText": "(ບໍ່ມີຂໍ້ຄວາມຄຳເຫັນ)", + "summaryBusy": "ກຳລັງສ້າງສະຫຼຸບຄຳເຫັນ...", + "summaryExported": "ສົ່ງອອກສະຫຼຸບຄຳເຫັນເປັນ PDF ແລ້ວ", + "summaryFailed": "ບໍ່ສາມາດສ້າງສະຫຼຸບຄຳເຫັນໄດ້", "filterLabel": "ຕົວກັ່ນຕອງ", "expandAll": "ຂະຫຍາຍທັງໝົດ", "collapseAll": "ຫຍໍ້ເຂົ້າທັງໝົດ", diff --git a/open-pdf-studio/js/i18n/locales/ms/properties.json b/open-pdf-studio/js/i18n/locales/ms/properties.json index 0cd0f2793..274bdfd61 100644 --- a/open-pdf-studio/js/i18n/locales/ms/properties.json +++ b/open-pdf-studio/js/i18n/locales/ms/properties.json @@ -379,17 +379,44 @@ "exportComments": "Eksport komen ke fail data...", "exportSelectedComments": "Eksport komen terpilih ke fail data...", "exportListCsv": "Eksport senarai ke CSV...", - "summarizeComingSoon": "Akan datang", "listExported": "Senarai ulasan dieksport ke CSV", "csvPage": "Halaman", "csvType": "Jenis", "csvAuthor": "Pengarang", - "csvDate": "Tarikh", "csvStatus": "Status", "csvSubject": "Subjek", "csvContents": "Kandungan", "csvColor": "Warna", "csvLayer": "Lapisan", + "csvId": "ID", + "csvCreated": "Dicipta", + "csvModified": "Diubah suai", + "csvStatusBy": "Status ditetapkan oleh", + "csvStatusAt": "Tarikh status", + "csvReplyCount": "Bilangan balasan", + "csvReplies": "Balasan", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Lebar", + "csvHeight": "Tinggi", + "filterBar": "Penapis", + "filterAll": "(Semua)", + "filterPages": "Halaman", + "filterDate": "Tarikh", + "filterFrom": "Dari", + "filterTo": "Hingga", + "filterClear": "Kosongkan penapis", + "setStatusSelected": "Tetapkan status yang dipilih", + "setStatusListed": "Tetapkan status semua yang disenaraikan", + "statusChanged": "Status diubah: {{count}}", + "summaryTitle": "Ringkasan ulasan", + "summaryDocument": "Dokumen", + "summaryCreated": "Dicipta", + "summaryCount": "Ulasan", + "summaryNoText": "(tiada teks ulasan)", + "summaryBusy": "Mencipta ringkasan ulasan...", + "summaryExported": "Ringkasan ulasan dieksport ke PDF", + "summaryFailed": "Ringkasan ulasan tidak dapat dicipta", "schedules": "Jadual", "addPage": "Tambah halaman", "pageLabels": "Label halaman (pembina nama)", diff --git a/open-pdf-studio/js/i18n/locales/nb/properties.json b/open-pdf-studio/js/i18n/locales/nb/properties.json index b434cefaa..c2ca9c4e3 100644 --- a/open-pdf-studio/js/i18n/locales/nb/properties.json +++ b/open-pdf-studio/js/i18n/locales/nb/properties.json @@ -379,17 +379,44 @@ "exportComments": "Eksporter kommentarer til datafil...", "exportSelectedComments": "Eksporter valgte kommentarer til datafil...", "exportListCsv": "Eksporter listen til CSV...", - "summarizeComingSoon": "Kommer snart", "listExported": "Kommentarlisten er eksportert til CSV", "csvPage": "Side", "csvType": "Type", "csvAuthor": "Forfatter", - "csvDate": "Dato", "csvStatus": "Status", "csvSubject": "Emne", "csvContents": "Innhold", "csvColor": "Farge", "csvLayer": "Lag", + "csvId": "ID", + "csvCreated": "Opprettet", + "csvModified": "Endret", + "csvStatusBy": "Status satt av", + "csvStatusAt": "Statusdato", + "csvReplyCount": "Antall svar", + "csvReplies": "Svar", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Bredde", + "csvHeight": "Høyde", + "filterBar": "Filtre", + "filterAll": "(Alle)", + "filterPages": "Sider", + "filterDate": "Dato", + "filterFrom": "Fra", + "filterTo": "Til", + "filterClear": "Fjern filtre", + "setStatusSelected": "Angi status for valgte", + "setStatusListed": "Angi status for alle viste", + "statusChanged": "Status endret: {{count}}", + "summaryTitle": "Kommentaroversikt", + "summaryDocument": "Dokument", + "summaryCreated": "Opprettet", + "summaryCount": "Kommentarer", + "summaryNoText": "(ingen kommentartekst)", + "summaryBusy": "Oppretter kommentaroversikt...", + "summaryExported": "Kommentaroversikt eksportert til PDF", + "summaryFailed": "Kunne ikke opprette kommentaroversikten", "schedules": "Lister", "addPage": "Legg til side", "pageLabels": "Sideetiketter (navnebygger)", diff --git a/open-pdf-studio/js/i18n/locales/nl/properties.json b/open-pdf-studio/js/i18n/locales/nl/properties.json index f05b10c10..b0cb6759f 100644 --- a/open-pdf-studio/js/i18n/locales/nl/properties.json +++ b/open-pdf-studio/js/i18n/locales/nl/properties.json @@ -358,17 +358,44 @@ "exportComments": "Opmerkingen exporteren naar gegevensbestand...", "exportSelectedComments": "Geselecteerde opmerkingen exporteren naar gegevensbestand...", "exportListCsv": "Lijst exporteren naar CSV...", - "summarizeComingSoon": "Binnenkort beschikbaar", "listExported": "Opmerkingenlijst geëxporteerd naar CSV", "csvPage": "Pagina", "csvType": "Type", "csvAuthor": "Auteur", - "csvDate": "Datum", "csvStatus": "Status", "csvSubject": "Onderwerp", "csvContents": "Inhoud", "csvColor": "Kleur", "csvLayer": "Laag", + "csvId": "ID", + "csvCreated": "Aangemaakt", + "csvModified": "Gewijzigd", + "csvStatusBy": "Status gezet door", + "csvStatusAt": "Statusdatum", + "csvReplyCount": "Aantal reacties", + "csvReplies": "Reacties", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Breedte", + "csvHeight": "Hoogte", + "filterBar": "Filters", + "filterAll": "(Alle)", + "filterPages": "Pagina's", + "filterDate": "Datum", + "filterFrom": "Van", + "filterTo": "Tot", + "filterClear": "Filters wissen", + "setStatusSelected": "Status van selectie instellen", + "setStatusListed": "Status van alle getoonde instellen", + "statusChanged": "Status gewijzigd: {{count}}", + "summaryTitle": "Overzicht van opmerkingen", + "summaryDocument": "Document", + "summaryCreated": "Gemaakt", + "summaryCount": "Opmerkingen", + "summaryNoText": "(geen opmerkingstekst)", + "summaryBusy": "Overzicht van opmerkingen maken...", + "summaryExported": "Overzicht van opmerkingen geëxporteerd naar PDF", + "summaryFailed": "Het overzicht van opmerkingen kon niet worden gemaakt", "measurements": "Metingen", "schedules": "Staten", "addPage": "Pagina toevoegen", diff --git a/open-pdf-studio/js/i18n/locales/pl/properties.json b/open-pdf-studio/js/i18n/locales/pl/properties.json index c16ed5f84..5260ed446 100644 --- a/open-pdf-studio/js/i18n/locales/pl/properties.json +++ b/open-pdf-studio/js/i18n/locales/pl/properties.json @@ -379,17 +379,44 @@ "exportComments": "Eksportuj komentarze do pliku danych...", "exportSelectedComments": "Eksportuj wybrane komentarze do pliku danych...", "exportListCsv": "Eksportuj listę do CSV...", - "summarizeComingSoon": "Wkrótce", "listExported": "Lista komentarzy wyeksportowana do CSV", "csvPage": "Strona", "csvType": "Typ", "csvAuthor": "Autor", - "csvDate": "Data", "csvStatus": "Status", "csvSubject": "Temat", "csvContents": "Treść", "csvColor": "Kolor", "csvLayer": "Warstwa", + "csvId": "ID", + "csvCreated": "Utworzono", + "csvModified": "Zmodyfikowano", + "csvStatusBy": "Status ustawiony przez", + "csvStatusAt": "Data statusu", + "csvReplyCount": "Liczba odpowiedzi", + "csvReplies": "Odpowiedzi", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Szerokość", + "csvHeight": "Wysokość", + "filterBar": "Filtry", + "filterAll": "(Wszystkie)", + "filterPages": "Strony", + "filterDate": "Data", + "filterFrom": "Od", + "filterTo": "Do", + "filterClear": "Wyczyść filtry", + "setStatusSelected": "Ustaw status zaznaczonych", + "setStatusListed": "Ustaw status wszystkich na liście", + "statusChanged": "Zmieniono status: {{count}}", + "summaryTitle": "Podsumowanie komentarzy", + "summaryDocument": "Dokument", + "summaryCreated": "Utworzono", + "summaryCount": "Komentarze", + "summaryNoText": "(brak tekstu komentarza)", + "summaryBusy": "Tworzenie podsumowania komentarzy...", + "summaryExported": "Podsumowanie komentarzy wyeksportowano do PDF", + "summaryFailed": "Nie można utworzyć podsumowania komentarzy", "schedules": "Zestawienia", "addPage": "Dodaj stronę", "pageLabels": "Etykiety stron (kreator nazw)", diff --git a/open-pdf-studio/js/i18n/locales/pt/properties.json b/open-pdf-studio/js/i18n/locales/pt/properties.json index 93dde3ec3..3eb37ecb9 100644 --- a/open-pdf-studio/js/i18n/locales/pt/properties.json +++ b/open-pdf-studio/js/i18n/locales/pt/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportar comentários para arquivo de dados...", "exportSelectedComments": "Exportar comentários selecionados para arquivo de dados...", "exportListCsv": "Exportar lista para CSV...", - "summarizeComingSoon": "Em breve", "listExported": "Lista de comentários exportada para CSV", "csvPage": "Página", "csvType": "Tipo", "csvAuthor": "Autor", - "csvDate": "Data", "csvStatus": "Estado", "csvSubject": "Assunto", "csvContents": "Conteúdo", "csvColor": "Cor", "csvLayer": "Camada", + "csvId": "ID", + "csvCreated": "Criado", + "csvModified": "Modificado", + "csvStatusBy": "Estado definido por", + "csvStatusAt": "Data do estado", + "csvReplyCount": "Número de respostas", + "csvReplies": "Respostas", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Largura", + "csvHeight": "Altura", + "filterBar": "Filtros", + "filterAll": "(Todos)", + "filterPages": "Páginas", + "filterDate": "Data", + "filterFrom": "De", + "filterTo": "Até", + "filterClear": "Limpar filtros", + "setStatusSelected": "Definir estado da seleção", + "setStatusListed": "Definir estado de todos os listados", + "statusChanged": "Estado alterado: {{count}}", + "summaryTitle": "Resumo dos comentários", + "summaryDocument": "Documento", + "summaryCreated": "Criado", + "summaryCount": "Comentários", + "summaryNoText": "(sem texto de comentário)", + "summaryBusy": "A criar o resumo dos comentários...", + "summaryExported": "Resumo dos comentários exportado para PDF", + "summaryFailed": "Não foi possível criar o resumo dos comentários", "schedules": "Quadros", "addPage": "Adicionar página", "pageLabels": "Rótulos de página (gerador de nomes)", diff --git a/open-pdf-studio/js/i18n/locales/ro/properties.json b/open-pdf-studio/js/i18n/locales/ro/properties.json index 85c10754f..967217b28 100644 --- a/open-pdf-studio/js/i18n/locales/ro/properties.json +++ b/open-pdf-studio/js/i18n/locales/ro/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportă comentarii în fișier de date...", "exportSelectedComments": "Exportă comentariile selectate în fișier de date...", "exportListCsv": "Exportă lista în CSV...", - "summarizeComingSoon": "În curând", "listExported": "Lista de comentarii a fost exportată în CSV", "csvPage": "Pagină", "csvType": "Tip", "csvAuthor": "Autor", - "csvDate": "Dată", "csvStatus": "Stare", "csvSubject": "Subiect", "csvContents": "Conținut", "csvColor": "Culoare", "csvLayer": "Strat", + "csvId": "ID", + "csvCreated": "Creat", + "csvModified": "Modificat", + "csvStatusBy": "Stare setată de", + "csvStatusAt": "Data stării", + "csvReplyCount": "Număr de răspunsuri", + "csvReplies": "Răspunsuri", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Lățime", + "csvHeight": "Înălțime", + "filterBar": "Filtre", + "filterAll": "(Toate)", + "filterPages": "Pagini", + "filterDate": "Dată", + "filterFrom": "De la", + "filterTo": "Până la", + "filterClear": "Șterge filtrele", + "setStatusSelected": "Setează starea selecției", + "setStatusListed": "Setează starea tuturor celor listate", + "statusChanged": "Stare modificată: {{count}}", + "summaryTitle": "Rezumatul comentariilor", + "summaryDocument": "Document", + "summaryCreated": "Creat", + "summaryCount": "Comentarii", + "summaryNoText": "(fără text de comentariu)", + "summaryBusy": "Se creează rezumatul comentariilor...", + "summaryExported": "Rezumatul comentariilor a fost exportat în PDF", + "summaryFailed": "Rezumatul comentariilor nu a putut fi creat", "schedules": "Extrase", "addPage": "Adăugare pagină", "pageLabels": "Etichete de pagină (generator de nume)", diff --git a/open-pdf-studio/js/i18n/locales/ru/properties.json b/open-pdf-studio/js/i18n/locales/ru/properties.json index 641f2c0c7..bdeb46e0b 100644 --- a/open-pdf-studio/js/i18n/locales/ru/properties.json +++ b/open-pdf-studio/js/i18n/locales/ru/properties.json @@ -379,17 +379,44 @@ "exportComments": "Экспорт комментариев в файл данных...", "exportSelectedComments": "Экспорт выбранных комментариев в файл данных...", "exportListCsv": "Экспортировать список в CSV...", - "summarizeComingSoon": "Скоро", "listExported": "Список комментариев экспортирован в CSV", "csvPage": "Страница", "csvType": "Тип", "csvAuthor": "Автор", - "csvDate": "Дата", "csvStatus": "Статус", "csvSubject": "Тема", "csvContents": "Содержание", "csvColor": "Цвет", "csvLayer": "Слой", + "csvId": "ИД", + "csvCreated": "Создано", + "csvModified": "Изменено", + "csvStatusBy": "Статус установил", + "csvStatusAt": "Дата статуса", + "csvReplyCount": "Число ответов", + "csvReplies": "Ответы", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Ширина", + "csvHeight": "Высота", + "filterBar": "Фильтры", + "filterAll": "(Все)", + "filterPages": "Страницы", + "filterDate": "Дата", + "filterFrom": "С", + "filterTo": "По", + "filterClear": "Сбросить фильтры", + "setStatusSelected": "Задать статус выделенных", + "setStatusListed": "Задать статус всех в списке", + "statusChanged": "Статус изменён: {{count}}", + "summaryTitle": "Сводка комментариев", + "summaryDocument": "Документ", + "summaryCreated": "Создано", + "summaryCount": "Комментарии", + "summaryNoText": "(нет текста комментария)", + "summaryBusy": "Создание сводки комментариев...", + "summaryExported": "Сводка комментариев экспортирована в PDF", + "summaryFailed": "Не удалось создать сводку комментариев", "schedules": "Спецификации", "addPage": "Добавить страницу", "pageLabels": "Метки страниц (конструктор имён)", diff --git a/open-pdf-studio/js/i18n/locales/sk/properties.json b/open-pdf-studio/js/i18n/locales/sk/properties.json index 00d8282dd..aef582596 100644 --- a/open-pdf-studio/js/i18n/locales/sk/properties.json +++ b/open-pdf-studio/js/i18n/locales/sk/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportovať komentáre do dátového súboru...", "exportSelectedComments": "Exportovať vybrané komentáre do dátového súboru...", "exportListCsv": "Exportovať zoznam do CSV...", - "summarizeComingSoon": "Už čoskoro", "listExported": "Zoznam komentárov bol exportovaný do CSV", "csvPage": "Strana", "csvType": "Typ", "csvAuthor": "Autor", - "csvDate": "Dátum", "csvStatus": "Stav", "csvSubject": "Predmet", "csvContents": "Obsah", "csvColor": "Farba", "csvLayer": "Vrstva", + "csvId": "ID", + "csvCreated": "Vytvorené", + "csvModified": "Zmenené", + "csvStatusBy": "Stav nastavil", + "csvStatusAt": "Dátum stavu", + "csvReplyCount": "Počet odpovedí", + "csvReplies": "Odpovede", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Šírka", + "csvHeight": "Výška", + "filterBar": "Filtre", + "filterAll": "(Všetko)", + "filterPages": "Strany", + "filterDate": "Dátum", + "filterFrom": "Od", + "filterTo": "Do", + "filterClear": "Vymazať filtre", + "setStatusSelected": "Nastaviť stav vybraných", + "setStatusListed": "Nastaviť stav všetkých zobrazených", + "statusChanged": "Stav zmenený: {{count}}", + "summaryTitle": "Súhrn komentárov", + "summaryDocument": "Dokument", + "summaryCreated": "Vytvorené", + "summaryCount": "Komentáre", + "summaryNoText": "(bez textu komentára)", + "summaryBusy": "Vytvára sa súhrn komentárov...", + "summaryExported": "Súhrn komentárov bol exportovaný do PDF", + "summaryFailed": "Súhrn komentárov sa nepodarilo vytvoriť", "schedules": "Výkazy", "addPage": "Pridať stranu", "pageLabels": "Menovky strán (tvorba názvov)", diff --git a/open-pdf-studio/js/i18n/locales/sr/properties.json b/open-pdf-studio/js/i18n/locales/sr/properties.json index bd332d539..8ff987a70 100644 --- a/open-pdf-studio/js/i18n/locales/sr/properties.json +++ b/open-pdf-studio/js/i18n/locales/sr/properties.json @@ -379,17 +379,44 @@ "exportComments": "Извези коментаре у датотеку података...", "exportSelectedComments": "Извези изабране коментаре у датотеку података...", "exportListCsv": "Извези листу у CSV...", - "summarizeComingSoon": "Ускоро", "listExported": "Листа коментара је извезена у CSV", "csvPage": "Страница", "csvType": "Врста", "csvAuthor": "Аутор", - "csvDate": "Датум", "csvStatus": "Статус", "csvSubject": "Предмет", "csvContents": "Садржај", "csvColor": "Боја", "csvLayer": "Слој", + "csvId": "ИД", + "csvCreated": "Направљено", + "csvModified": "Измењено", + "csvStatusBy": "Статус поставио", + "csvStatusAt": "Датум статуса", + "csvReplyCount": "Број одговора", + "csvReplies": "Одговори", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Ширина", + "csvHeight": "Висина", + "filterBar": "Филтери", + "filterAll": "(Сви)", + "filterPages": "Странице", + "filterDate": "Датум", + "filterFrom": "Од", + "filterTo": "До", + "filterClear": "Обриши филтере", + "setStatusSelected": "Постави статус изабраних", + "setStatusListed": "Постави статус свих приказаних", + "statusChanged": "Статус промењен: {{count}}", + "summaryTitle": "Резиме коментара", + "summaryDocument": "Документ", + "summaryCreated": "Направљено", + "summaryCount": "Коментари", + "summaryNoText": "(нема текста коментара)", + "summaryBusy": "Прављење резимеа коментара...", + "summaryExported": "Резиме коментара је извезен у PDF", + "summaryFailed": "Резиме коментара није могао да се направи", "schedules": "Спецификације", "addPage": "Додај страницу", "pageLabels": "Ознаке страница (израда назива)", diff --git a/open-pdf-studio/js/i18n/locales/sv/properties.json b/open-pdf-studio/js/i18n/locales/sv/properties.json index 4715be893..2eecc9445 100644 --- a/open-pdf-studio/js/i18n/locales/sv/properties.json +++ b/open-pdf-studio/js/i18n/locales/sv/properties.json @@ -379,17 +379,44 @@ "exportComments": "Exportera kommentarer till datafil...", "exportSelectedComments": "Exportera valda kommentarer till datafil...", "exportListCsv": "Exportera listan till CSV...", - "summarizeComingSoon": "Kommer snart", "listExported": "Kommentarslistan har exporterats till CSV", "csvPage": "Sida", "csvType": "Typ", "csvAuthor": "Författare", - "csvDate": "Datum", "csvStatus": "Status", "csvSubject": "Ämne", "csvContents": "Innehåll", "csvColor": "Färg", "csvLayer": "Lager", + "csvId": "ID", + "csvCreated": "Skapad", + "csvModified": "Ändrad", + "csvStatusBy": "Status satt av", + "csvStatusAt": "Statusdatum", + "csvReplyCount": "Antal svar", + "csvReplies": "Svar", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Bredd", + "csvHeight": "Höjd", + "filterBar": "Filter", + "filterAll": "(Alla)", + "filterPages": "Sidor", + "filterDate": "Datum", + "filterFrom": "Från", + "filterTo": "Till", + "filterClear": "Rensa filter", + "setStatusSelected": "Ange status för markerade", + "setStatusListed": "Ange status för alla listade", + "statusChanged": "Status ändrad: {{count}}", + "summaryTitle": "Kommentarssammanfattning", + "summaryDocument": "Dokument", + "summaryCreated": "Skapad", + "summaryCount": "Kommentarer", + "summaryNoText": "(ingen kommentarstext)", + "summaryBusy": "Skapar kommentarssammanfattning...", + "summaryExported": "Kommentarssammanfattning exporterad till PDF", + "summaryFailed": "Det gick inte att skapa kommentarssammanfattningen", "schedules": "Förteckningar", "addPage": "Lägg till sida", "pageLabels": "Sidetiketter (namnbyggare)", diff --git a/open-pdf-studio/js/i18n/locales/sw/properties.json b/open-pdf-studio/js/i18n/locales/sw/properties.json index 9bfbb7181..ce525573d 100644 --- a/open-pdf-studio/js/i18n/locales/sw/properties.json +++ b/open-pdf-studio/js/i18n/locales/sw/properties.json @@ -379,17 +379,44 @@ "exportComments": "Hamisha maoni kwenye faili ya data...", "exportSelectedComments": "Hamisha maoni yaliyochaguliwa kwenye faili ya data...", "exportListCsv": "Hamisha orodha kwenda CSV...", - "summarizeComingSoon": "Inakuja hivi karibuni", "listExported": "Orodha ya maoni imehamishwa kwenda CSV", "csvPage": "Ukurasa", "csvType": "Aina", "csvAuthor": "Mwandishi", - "csvDate": "Tarehe", "csvStatus": "Hali", "csvSubject": "Mada", "csvContents": "Maudhui", "csvColor": "Rangi", "csvLayer": "Tabaka", + "csvId": "Kitambulisho", + "csvCreated": "Imeundwa", + "csvModified": "Imebadilishwa", + "csvStatusBy": "Hali imewekwa na", + "csvStatusAt": "Tarehe ya hali", + "csvReplyCount": "Idadi ya majibu", + "csvReplies": "Majibu", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Upana", + "csvHeight": "Urefu", + "filterBar": "Vichujio", + "filterAll": "(Zote)", + "filterPages": "Kurasa", + "filterDate": "Tarehe", + "filterFrom": "Kuanzia", + "filterTo": "Hadi", + "filterClear": "Futa vichujio", + "setStatusSelected": "Weka hali ya vilivyochaguliwa", + "setStatusListed": "Weka hali ya vyote vilivyoorodheshwa", + "statusChanged": "Hali imebadilishwa: {{count}}", + "summaryTitle": "Muhtasari wa maoni", + "summaryDocument": "Hati", + "summaryCreated": "Imeundwa", + "summaryCount": "Maoni", + "summaryNoText": "(hakuna maandishi ya maoni)", + "summaryBusy": "Inaunda muhtasari wa maoni...", + "summaryExported": "Muhtasari wa maoni umehamishwa kwenda PDF", + "summaryFailed": "Muhtasari wa maoni haukuweza kuundwa", "schedules": "Majedwali ya orodha", "addPage": "Ongeza ukurasa", "pageLabels": "Lebo za kurasa (kiunda majina)", diff --git a/open-pdf-studio/js/i18n/locales/ta/properties.json b/open-pdf-studio/js/i18n/locales/ta/properties.json index 10fd71721..f841c4e4f 100644 --- a/open-pdf-studio/js/i18n/locales/ta/properties.json +++ b/open-pdf-studio/js/i18n/locales/ta/properties.json @@ -379,17 +379,44 @@ "exportComments": "கருத்துகளை தரவு கோப்பில் ஏற்றுமதி...", "exportSelectedComments": "தேர்ந்தெடுத்த கருத்துகளை தரவு கோப்பில் ஏற்றுமதி...", "exportListCsv": "பட்டியலை CSV-க்கு ஏற்றுமதி செய்...", - "summarizeComingSoon": "விரைவில் வருகிறது", "listExported": "கருத்துப் பட்டியல் CSV-க்கு ஏற்றுமதி செய்யப்பட்டது", "csvPage": "பக்கம்", "csvType": "வகை", "csvAuthor": "ஆசிரியர்", - "csvDate": "தேதி", "csvStatus": "நிலை", "csvSubject": "பொருள்", "csvContents": "உள்ளடக்கம்", "csvColor": "நிறம்", "csvLayer": "அடுக்கு", + "csvId": "அடையாளம்", + "csvCreated": "உருவாக்கப்பட்டது", + "csvModified": "மாற்றப்பட்டது", + "csvStatusBy": "நிலையை அமைத்தவர்", + "csvStatusAt": "நிலை தேதி", + "csvReplyCount": "பதில்களின் எண்ணிக்கை", + "csvReplies": "பதில்கள்", + "csvX": "X", + "csvY": "Y", + "csvWidth": "அகலம்", + "csvHeight": "உயரம்", + "filterBar": "வடிகட்டிகள்", + "filterAll": "(அனைத்தும்)", + "filterPages": "பக்கங்கள்", + "filterDate": "தேதி", + "filterFrom": "இருந்து", + "filterTo": "வரை", + "filterClear": "வடிகட்டிகளை அழி", + "setStatusSelected": "தேர்ந்தெடுத்தவற்றின் நிலையை அமை", + "setStatusListed": "பட்டியலில் உள்ள அனைத்தின் நிலையை அமை", + "statusChanged": "நிலை மாற்றப்பட்டது: {{count}}", + "summaryTitle": "கருத்துச் சுருக்கம்", + "summaryDocument": "ஆவணம்", + "summaryCreated": "உருவாக்கப்பட்டது", + "summaryCount": "கருத்துகள்", + "summaryNoText": "(கருத்து உரை இல்லை)", + "summaryBusy": "கருத்துச் சுருக்கம் உருவாக்கப்படுகிறது...", + "summaryExported": "கருத்துச் சுருக்கம் PDF-க்கு ஏற்றுமதி செய்யப்பட்டது", + "summaryFailed": "கருத்துச் சுருக்கத்தை உருவாக்க முடியவில்லை", "schedules": "அட்டவணைகள்", "addPage": "பக்கத்தைச் சேர்", "pageLabels": "பக்க லேபிள்கள் (பெயர் உருவாக்கி)", diff --git a/open-pdf-studio/js/i18n/locales/th/properties.json b/open-pdf-studio/js/i18n/locales/th/properties.json index 17a4bb6b1..f6a85ccf0 100644 --- a/open-pdf-studio/js/i18n/locales/th/properties.json +++ b/open-pdf-studio/js/i18n/locales/th/properties.json @@ -379,17 +379,44 @@ "exportComments": "ส่งออกความคิดเห็นไปยังไฟล์ข้อมูล...", "exportSelectedComments": "ส่งออกความคิดเห็นที่เลือกไปยังไฟล์ข้อมูล...", "exportListCsv": "ส่งออกรายการเป็น CSV...", - "summarizeComingSoon": "เร็วๆ นี้", "listExported": "ส่งออกรายการความคิดเห็นเป็น CSV แล้ว", "csvPage": "หน้า", "csvType": "ประเภท", "csvAuthor": "ผู้เขียน", - "csvDate": "วันที่", "csvStatus": "สถานะ", "csvSubject": "หัวเรื่อง", "csvContents": "เนื้อหา", "csvColor": "สี", "csvLayer": "เลเยอร์", + "csvId": "รหัส", + "csvCreated": "สร้างเมื่อ", + "csvModified": "แก้ไขเมื่อ", + "csvStatusBy": "สถานะตั้งโดย", + "csvStatusAt": "วันที่ของสถานะ", + "csvReplyCount": "จำนวนการตอบกลับ", + "csvReplies": "การตอบกลับ", + "csvX": "X", + "csvY": "Y", + "csvWidth": "ความกว้าง", + "csvHeight": "ความสูง", + "filterBar": "ตัวกรอง", + "filterAll": "(ทั้งหมด)", + "filterPages": "หน้า", + "filterDate": "วันที่", + "filterFrom": "จาก", + "filterTo": "ถึง", + "filterClear": "ล้างตัวกรอง", + "setStatusSelected": "ตั้งสถานะของรายการที่เลือก", + "setStatusListed": "ตั้งสถานะของทุกรายการในรายการ", + "statusChanged": "เปลี่ยนสถานะแล้ว: {{count}}", + "summaryTitle": "สรุปความคิดเห็น", + "summaryDocument": "เอกสาร", + "summaryCreated": "สร้างเมื่อ", + "summaryCount": "ความคิดเห็น", + "summaryNoText": "(ไม่มีข้อความความคิดเห็น)", + "summaryBusy": "กำลังสร้างสรุปความคิดเห็น...", + "summaryExported": "ส่งออกสรุปความคิดเห็นเป็น PDF แล้ว", + "summaryFailed": "ไม่สามารถสร้างสรุปความคิดเห็นได้", "schedules": "ตารางรายการ", "addPage": "เพิ่มหน้า", "pageLabels": "ป้ายชื่อหน้า (ตัวสร้างชื่อ)", diff --git a/open-pdf-studio/js/i18n/locales/tr/properties.json b/open-pdf-studio/js/i18n/locales/tr/properties.json index 4d580cd30..334ea7043 100644 --- a/open-pdf-studio/js/i18n/locales/tr/properties.json +++ b/open-pdf-studio/js/i18n/locales/tr/properties.json @@ -379,17 +379,44 @@ "exportComments": "Yorumları veri dosyasına aktar...", "exportSelectedComments": "Seçili yorumları veri dosyasına aktar...", "exportListCsv": "Listeyi CSV olarak dışa aktar...", - "summarizeComingSoon": "Yakında", "listExported": "Yorum listesi CSV olarak dışa aktarıldı", "csvPage": "Sayfa", "csvType": "Tür", "csvAuthor": "Yazar", - "csvDate": "Tarih", "csvStatus": "Durum", "csvSubject": "Konu", "csvContents": "İçerik", "csvColor": "Renk", "csvLayer": "Katman", + "csvId": "Kimlik", + "csvCreated": "Oluşturulma", + "csvModified": "Değiştirilme", + "csvStatusBy": "Durumu ayarlayan", + "csvStatusAt": "Durum tarihi", + "csvReplyCount": "Yanıt sayısı", + "csvReplies": "Yanıtlar", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Genişlik", + "csvHeight": "Yükseklik", + "filterBar": "Filtreler", + "filterAll": "(Tümü)", + "filterPages": "Sayfalar", + "filterDate": "Tarih", + "filterFrom": "Başlangıç", + "filterTo": "Bitiş", + "filterClear": "Filtreleri temizle", + "setStatusSelected": "Seçilenlerin durumunu ayarla", + "setStatusListed": "Listelenen tümünün durumunu ayarla", + "statusChanged": "Durum değiştirildi: {{count}}", + "summaryTitle": "Yorum özeti", + "summaryDocument": "Belge", + "summaryCreated": "Oluşturulma", + "summaryCount": "Yorumlar", + "summaryNoText": "(yorum metni yok)", + "summaryBusy": "Yorum özeti oluşturuluyor...", + "summaryExported": "Yorum özeti PDF olarak dışa aktarıldı", + "summaryFailed": "Yorum özeti oluşturulamadı", "schedules": "Çizelgeler", "addPage": "Sayfa ekle", "pageLabels": "Sayfa etiketleri (ad oluşturucu)", diff --git a/open-pdf-studio/js/i18n/locales/uk/properties.json b/open-pdf-studio/js/i18n/locales/uk/properties.json index 4195fd72b..5934b964e 100644 --- a/open-pdf-studio/js/i18n/locales/uk/properties.json +++ b/open-pdf-studio/js/i18n/locales/uk/properties.json @@ -379,17 +379,44 @@ "exportComments": "Експорт коментарів у файл даних...", "exportSelectedComments": "Експорт вибраних коментарів у файл даних...", "exportListCsv": "Експортувати список у CSV...", - "summarizeComingSoon": "Незабаром", "listExported": "Список коментарів експортовано в CSV", "csvPage": "Сторінка", "csvType": "Тип", "csvAuthor": "Автор", - "csvDate": "Дата", "csvStatus": "Статус", "csvSubject": "Тема", "csvContents": "Вміст", "csvColor": "Колір", "csvLayer": "Шар", + "csvId": "ІД", + "csvCreated": "Створено", + "csvModified": "Змінено", + "csvStatusBy": "Статус встановив", + "csvStatusAt": "Дата статусу", + "csvReplyCount": "Кількість відповідей", + "csvReplies": "Відповіді", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Ширина", + "csvHeight": "Висота", + "filterBar": "Фільтри", + "filterAll": "(Усі)", + "filterPages": "Сторінки", + "filterDate": "Дата", + "filterFrom": "З", + "filterTo": "По", + "filterClear": "Скинути фільтри", + "setStatusSelected": "Встановити статус вибраних", + "setStatusListed": "Встановити статус усіх у списку", + "statusChanged": "Статус змінено: {{count}}", + "summaryTitle": "Зведення коментарів", + "summaryDocument": "Документ", + "summaryCreated": "Створено", + "summaryCount": "Коментарі", + "summaryNoText": "(немає тексту коментаря)", + "summaryBusy": "Створення зведення коментарів...", + "summaryExported": "Зведення коментарів експортовано в PDF", + "summaryFailed": "Не вдалося створити зведення коментарів", "schedules": "Специфікації", "addPage": "Додати сторінку", "pageLabels": "Мітки сторінок (конструктор назв)", diff --git a/open-pdf-studio/js/i18n/locales/ur/properties.json b/open-pdf-studio/js/i18n/locales/ur/properties.json index 0d126e32a..a9721801a 100644 --- a/open-pdf-studio/js/i18n/locales/ur/properties.json +++ b/open-pdf-studio/js/i18n/locales/ur/properties.json @@ -379,17 +379,44 @@ "exportComments": "تبصرے ڈیٹا فائل میں برآمد کریں...", "exportSelectedComments": "منتخب تبصرے ڈیٹا فائل میں برآمد کریں...", "exportListCsv": "فہرست CSV میں برآمد کریں...", - "summarizeComingSoon": "جلد آ رہا ہے", "listExported": "تبصروں کی فہرست CSV میں برآمد ہو گئی", "csvPage": "صفحہ", "csvType": "قسم", "csvAuthor": "مصنف", - "csvDate": "تاریخ", "csvStatus": "حالت", "csvSubject": "موضوع", "csvContents": "مواد", "csvColor": "رنگ", "csvLayer": "پرت", + "csvId": "شناخت", + "csvCreated": "تخلیق شدہ", + "csvModified": "ترمیم شدہ", + "csvStatusBy": "اسٹیٹس مقرر کنندہ", + "csvStatusAt": "اسٹیٹس کی تاریخ", + "csvReplyCount": "جوابات کی تعداد", + "csvReplies": "جوابات", + "csvX": "X", + "csvY": "Y", + "csvWidth": "چوڑائی", + "csvHeight": "اونچائی", + "filterBar": "فلٹرز", + "filterAll": "(تمام)", + "filterPages": "صفحات", + "filterDate": "تاریخ", + "filterFrom": "سے", + "filterTo": "تک", + "filterClear": "فلٹرز صاف کریں", + "setStatusSelected": "منتخب کا اسٹیٹس مقرر کریں", + "setStatusListed": "فہرست میں موجود تمام کا اسٹیٹس مقرر کریں", + "statusChanged": "اسٹیٹس تبدیل ہوا: {{count}}", + "summaryTitle": "تبصروں کا خلاصہ", + "summaryDocument": "دستاویز", + "summaryCreated": "تخلیق شدہ", + "summaryCount": "تبصرے", + "summaryNoText": "(تبصرے کا کوئی متن نہیں)", + "summaryBusy": "تبصروں کا خلاصہ بنایا جا رہا ہے...", + "summaryExported": "تبصروں کا خلاصہ PDF میں برآمد کر دیا گیا", + "summaryFailed": "تبصروں کا خلاصہ نہیں بنایا جا سکا", "schedules": "شیڈولز", "addPage": "صفحہ شامل کریں", "pageLabels": "صفحہ لیبلز (نام ساز)", diff --git a/open-pdf-studio/js/i18n/locales/vi/properties.json b/open-pdf-studio/js/i18n/locales/vi/properties.json index c54bbff22..8d9d5e653 100644 --- a/open-pdf-studio/js/i18n/locales/vi/properties.json +++ b/open-pdf-studio/js/i18n/locales/vi/properties.json @@ -379,17 +379,44 @@ "exportComments": "Xuất nhận xét ra tệp dữ liệu...", "exportSelectedComments": "Xuất nhận xét đã chọn ra tệp dữ liệu...", "exportListCsv": "Xuất danh sách ra CSV...", - "summarizeComingSoon": "Sắp ra mắt", "listExported": "Đã xuất danh sách nhận xét ra CSV", "csvPage": "Trang", "csvType": "Loại", "csvAuthor": "Tác giả", - "csvDate": "Ngày", "csvStatus": "Trạng thái", "csvSubject": "Chủ đề", "csvContents": "Nội dung", "csvColor": "Màu", "csvLayer": "Lớp", + "csvId": "ID", + "csvCreated": "Ngày tạo", + "csvModified": "Ngày sửa", + "csvStatusBy": "Trạng thái đặt bởi", + "csvStatusAt": "Ngày trạng thái", + "csvReplyCount": "Số phản hồi", + "csvReplies": "Phản hồi", + "csvX": "X", + "csvY": "Y", + "csvWidth": "Chiều rộng", + "csvHeight": "Chiều cao", + "filterBar": "Bộ lọc", + "filterAll": "(Tất cả)", + "filterPages": "Trang", + "filterDate": "Ngày", + "filterFrom": "Từ", + "filterTo": "Đến", + "filterClear": "Xóa bộ lọc", + "setStatusSelected": "Đặt trạng thái cho mục đã chọn", + "setStatusListed": "Đặt trạng thái cho tất cả mục trong danh sách", + "statusChanged": "Đã đổi trạng thái: {{count}}", + "summaryTitle": "Tóm tắt nhận xét", + "summaryDocument": "Tài liệu", + "summaryCreated": "Ngày tạo", + "summaryCount": "Nhận xét", + "summaryNoText": "(không có nội dung nhận xét)", + "summaryBusy": "Đang tạo bản tóm tắt nhận xét...", + "summaryExported": "Đã xuất bản tóm tắt nhận xét ra PDF", + "summaryFailed": "Không thể tạo bản tóm tắt nhận xét", "schedules": "Bảng thống kê", "addPage": "Thêm trang", "pageLabels": "Nhãn trang (trình tạo tên)", diff --git a/open-pdf-studio/js/i18n/locales/zh/properties.json b/open-pdf-studio/js/i18n/locales/zh/properties.json index 0443b6fbb..7a05c2850 100644 --- a/open-pdf-studio/js/i18n/locales/zh/properties.json +++ b/open-pdf-studio/js/i18n/locales/zh/properties.json @@ -379,17 +379,44 @@ "exportComments": "将批注导出到数据文件...", "exportSelectedComments": "将选定批注导出到数据文件...", "exportListCsv": "将列表导出为 CSV...", - "summarizeComingSoon": "即将推出", "listExported": "注释列表已导出为 CSV", "csvPage": "页面", "csvType": "类型", "csvAuthor": "作者", - "csvDate": "日期", "csvStatus": "状态", "csvSubject": "主题", "csvContents": "内容", "csvColor": "颜色", "csvLayer": "图层", + "csvId": "ID", + "csvCreated": "创建时间", + "csvModified": "修改时间", + "csvStatusBy": "状态设置者", + "csvStatusAt": "状态日期", + "csvReplyCount": "回复数", + "csvReplies": "回复", + "csvX": "X", + "csvY": "Y", + "csvWidth": "宽度", + "csvHeight": "高度", + "filterBar": "筛选", + "filterAll": "(全部)", + "filterPages": "页面", + "filterDate": "日期", + "filterFrom": "从", + "filterTo": "至", + "filterClear": "清除筛选", + "setStatusSelected": "设置所选项的状态", + "setStatusListed": "设置所有列出项的状态", + "statusChanged": "状态已更改:{{count}}", + "summaryTitle": "注释摘要", + "summaryDocument": "文档", + "summaryCreated": "创建时间", + "summaryCount": "注释", + "summaryNoText": "(无注释文本)", + "summaryBusy": "正在创建注释摘要...", + "summaryExported": "注释摘要已导出为 PDF", + "summaryFailed": "无法创建注释摘要", "schedules": "明细表", "addPage": "添加页面", "pageLabels": "页面标签(名称生成器)", diff --git a/open-pdf-studio/js/solid/components/left-panel/panels/AnnotationsPanel.jsx b/open-pdf-studio/js/solid/components/left-panel/panels/AnnotationsPanel.jsx index 1826ed5e5..7f27bdbb6 100644 --- a/open-pdf-studio/js/solid/components/left-panel/panels/AnnotationsPanel.jsx +++ b/open-pdf-studio/js/solid/components/left-panel/panels/AnnotationsPanel.jsx @@ -1,8 +1,12 @@ import { For, Show, createSignal, createEffect, onCleanup } from 'solid-js'; import { activeTab, collapsed } from '../../../stores/leftPanelStore.js'; -import { items, countText, emptyMessage, sortMode, setSortMode, filterMode, setFilterMode, hiddenStatuses, toggleHiddenStatus, collapsedGroups, toggleGroup, expandAllGroups, collapseAllGroups } from '../../../stores/panels/annotationsStore.js'; +import { + items, countText, emptyMessage, sortMode, setSortMode, filterMode, setFilterMode, hiddenStatuses, toggleHiddenStatus, + collapsedGroups, toggleGroup, expandAllGroups, collapseAllGroups, + listFilter, updateListFilter, clearListFilter, anyListFilterActive, filterOptions, filterBarOpen, setFilterBarOpen, +} from '../../../stores/panels/annotationsStore.js'; import { useTranslation } from '../../../../i18n/useTranslation.js'; -import { state, clearSelection, getActiveDocument } from '../../../../core/state.js'; +import { state, clearSelection, getActiveDocument, getAnnotationBounds } from '../../../../core/state.js'; import { commitAnnotationMutation, deleteAnnotationsWithUndo } from '../../../../annotations/mutations.js'; import { expandCorrectionGroups } from '../../../../annotations/corrections/model.js'; import { @@ -131,7 +135,7 @@ export default function AnnotationsPanel() { }; // De lijst zoals het paneel haar toont (filter, groepering, sortering) als - // CSV, UTF-8 met BOM. + // CSV met alle velden (#383), UTF-8 met BOM. const exportListCsv = async () => { const doc = getActiveDocument(); if (!doc) return; @@ -155,6 +159,7 @@ export default function AnnotationsPanel() { return i18next.t(`annotation.status${hoofd}`, { ns: 'context', defaultValue: hoofd }); }, laagNaam: (a) => lagen.layerDisplayName(lagen.layerOf(doc, a), standaardLaag), + omhullende: (a) => getAnnotationBounds(a), }; const koppen = exp.CSV_KOLOMMEN.map(k => i18next.t(`leftPanel.csv${k.charAt(0).toUpperCase()}${k.slice(1)}`, { ns: 'properties' })); const csv = exp.bouwCsv(koppen, anns.map(a => exp.opmerkingRij(a, namen))); @@ -165,6 +170,61 @@ export default function AnnotationsPanel() { statusBar.updateStatusMessage(i18next.t('leftPanel.listExported', { ns: 'properties' })); }; + // Opmerkingen samenvatten (#383): de lijst zoals het paneel haar toont als + // PDF-rapport met per opmerking een uitsnede van haar plek. + const exportSummaryPdf = async () => { + const doc = getActiveDocument(); + if (!doc) return; + const [exp, summary] = await Promise.all([ + import('../../../../annotations/opmerkingen-export.js'), + import('../../../../ui/panels/annotations-summary.js'), + ]); + const anns = exp.opmerkingenInLijstvolgorde(items(), doc.annotations); + if (anns.length > 0) await summary.exportAnnotationsSummaryPdf(anns); + }; + + // Status van veel opmerkingen tegelijk (#383): de selectie, of alles wat + // de lijst nu toont (na filters). Eén ongedaan-stap. + const selectedAnnotations = () => { + const doc = getActiveDocument(); + if (!doc) return []; + return doc.selectedAnnotations?.length ? doc.selectedAnnotations + : (doc.selectedAnnotation ? [doc.selectedAnnotation] : []); + }; + + const setStatusBulk = async (bron, status) => { + const doc = getActiveDocument(); + if (!doc) return; + const [exp, summary, rendering, list] = await Promise.all([ + import('../../../../annotations/opmerkingen-export.js'), + import('../../../../ui/panels/annotations-summary.js'), + import('../../../../annotations/rendering.js'), + import('../../../../ui/panels/annotations-list.js'), + ]); + const anns = bron === 'selection' ? selectedAnnotations() : exp.opmerkingenInLijstvolgorde(items(), doc.annotations); + if (summary.setStatusOfAnnotations(anns, status) === 0) return; + if (doc.viewMode === 'continuous') rendering.redrawContinuous(); + else rendering.redrawAnnotations(); + list.updateAnnotationsList(); + if (doc.selectedAnnotation && anns.includes(doc.selectedAnnotation)) { + const props = await import('../../../../ui/panels/properties-panel.js'); + if (selectedAnnotations().length > 1) props.showMultiSelectionProperties(); + else props.showProperties(doc.selectedAnnotation); + } + }; + + // Filterbalk (#383): auteur, soort, pagina's en datum, over alle pagina's. + const refreshList = () => import('../../../../ui/panels/annotations-list.js').then(m => m.updateAnnotationsList()); + const setListFilter = (patch) => { + updateListFilter(patch); + refreshList(); + }; + const clearFilters = () => { + clearListFilter(); + for (const key of [...hiddenStatuses()]) toggleHiddenStatus(key); + refreshList(); + }; + const cutSelected = () => { const doc = getActiveDocument(); const ann = doc?.selectedAnnotation; @@ -320,9 +380,12 @@ export default function AnnotationsPanel() {
- {/* Summarize / Import / Export (#383). Samenvatten (PDF-rapport - met uitsneden) volgt nog. */} -
+ {/* Summarize / Import / Export (#383): samenvatten is het + PDF-rapport van de lijst, met uitsneden. */} +
hasListRows() && closeAndRun(exportSummaryPdf)} + > {t('leftPanel.summarize')}
@@ -357,6 +420,44 @@ export default function AnnotationsPanel() {
+ {/* Status in bulk (#383) — submenu's */} +
+ + {t('leftPanel.setStatusSelected')} + + + + +
+ {statusFilterOptions.map(opt => ( +
closeAndRun(() => setStatusBulk('selection', opt.key))}> + + {tContext(opt.label)} +
+ ))} +
+
+
+
+ + {t('leftPanel.setStatusListed')} + + + + +
+ {statusFilterOptions.map(opt => ( +
closeAndRun(() => setStatusBulk('listed', opt.key))}> + + {tContext(opt.label)} +
+ ))} +
+
+
+ +
+ {/* Group By — submenu */}
@@ -412,6 +513,21 @@ export default function AnnotationsPanel() { {tContext(opt.label)}
))} +
+
closeAndRun(() => setFilterBarOpen(!filterBarOpen()))} + > + {filterBarOpen() ? '\u2713' : ''} + {t('leftPanel.filterBar')} +
+
anyListFilterActive() && closeAndRun(clearFilters)} + > + + {t('leftPanel.filterClear')} +
@@ -440,6 +556,15 @@ export default function AnnotationsPanel() {
+ + +
+ + + + + +
+ setListFilter({ paginaVan: e.target.value })} /> + - + setListFilter({ paginaTot: e.target.value })} /> +
+ +
+ setListFilter({ datumVan: e.target.value })} /> + - + setListFilter({ datumTot: e.target.value })} /> +
+
+ +
+
+
{emptyMessage()}
diff --git a/open-pdf-studio/js/solid/stores/panels/annotationsStore.js b/open-pdf-studio/js/solid/stores/panels/annotationsStore.js index af4b72b77..5febb4a92 100644 --- a/open-pdf-studio/js/solid/stores/panels/annotationsStore.js +++ b/open-pdf-studio/js/solid/stores/panels/annotationsStore.js @@ -1,4 +1,5 @@ import { createSignal } from 'solid-js'; +import { leegFilter, filterActief } from '../../../annotations/opmerkingen-overzicht.js'; const [items, setItems] = createSignal([]); const [countText, setCountText] = createSignal('0 annotations'); @@ -52,6 +53,26 @@ function toggleHiddenStatus(statusKey) { }).catch(() => { /* renderer nog niet geladen */ }); } +// Filterbalk van het opmerkingenoverzicht (#383): auteur, soort, pagina- en +// datumbereik. Werkt alleen op de lijst (en daarmee op CSV en rapport); het +// statusfilter hierboven werkt ook op het canvas. +const [listFilter, setListFilterSignal] = createSignal(leegFilter()); +const [filterOptions, setFilterOptions] = createSignal({ authors: [], types: [] }); +const [filterBarOpen, setFilterBarOpen] = createSignal(false); + +function updateListFilter(patch) { + setListFilterSignal(prev => ({ ...prev, ...(patch || {}) })); +} + +function clearListFilter() { + setListFilterSignal(leegFilter()); +} + +/** Staat er iets in de filterbalk of in het statusfilter? */ +function anyListFilterActive() { + return filterActief(listFilter()) || hiddenStatuses().size > 0; +} + function toggleGroup(groupKey) { setCollapsedGroups(prev => { const next = new Set(prev); @@ -77,4 +98,7 @@ export { filterMode, setFilterMode, hiddenStatuses, toggleHiddenStatus, isStatusHidden, collapsedGroups, toggleGroup, expandAllGroups, collapseAllGroups, + listFilter, updateListFilter, clearListFilter, anyListFilterActive, + filterOptions, setFilterOptions, + filterBarOpen, setFilterBarOpen, }; diff --git a/open-pdf-studio/js/solid/stores/panels/annotationsStore.test.mjs b/open-pdf-studio/js/solid/stores/panels/annotationsStore.test.mjs index dd7176918..073812c9e 100644 --- a/open-pdf-studio/js/solid/stores/panels/annotationsStore.test.mjs +++ b/open-pdf-studio/js/solid/stores/panels/annotationsStore.test.mjs @@ -40,3 +40,23 @@ test("geen status telt als 'none'", () => { toggleHiddenStatus('none'); assert.equal(isStatusHidden({}), false); }); + +// Filterbalk van het overzicht (#383): de store bewaart de invoer zoals hij +// getypt is; schoonmaken gebeurt bij het filteren (opmerkingen-overzicht.js). +test('filterbalk: bijwerken, actief-vlag en wissen', async () => { + const { listFilter, updateListFilter, clearListFilter, anyListFilterActive } = await import('./annotationsStore.js'); + assert.equal(anyListFilterActive(), false); + updateListFilter({ auteur: 'Anna' }); + updateListFilter({ paginaVan: '3' }); + assert.equal(listFilter().auteur, 'Anna'); + assert.equal(listFilter().paginaVan, '3'); + assert.equal(anyListFilterActive(), true); + clearListFilter(); + assert.equal(listFilter().auteur, ''); + assert.equal(anyListFilterActive(), false); + // Een uitgevinkte status telt ook als actief filter. + toggleHiddenStatus('rejected'); + assert.equal(anyListFilterActive(), true); + toggleHiddenStatus('rejected'); + assert.equal(anyListFilterActive(), false); +}); diff --git a/open-pdf-studio/js/ui/panels/annotations-list.js b/open-pdf-studio/js/ui/panels/annotations-list.js index a840b94f5..8a216e07e 100644 --- a/open-pdf-studio/js/ui/panels/annotations-list.js +++ b/open-pdf-studio/js/ui/panels/annotations-list.js @@ -10,6 +10,10 @@ import { stopAlleWielScrollers } from '../../pdf/wiel-scroll.js'; import { redrawAnnotations } from '../../annotations/rendering.js'; import { paginaNaarWeergave } from '../../pdf/weergave-ruimte.js'; import { switchLeftPanelTab } from './left-panel.js'; +import { + filterOpmerkingen, sorteerOpmerkingen, auteursIn, typesIn, laatsteStatusAuteur, +} from '../../annotations/opmerkingen-overzicht.js'; +import { listFilter, setFilterOptions } from '../../solid/stores/panels/annotationsStore.js'; import { leftPanelCollapsed, leftPanelActiveTab as activeTab, setAnnotationItems as setItems, setAnnotationCountText as setCountText, @@ -118,6 +122,15 @@ export function updateAnnotationsList(filterValue) { // gegarandeerd hetzelfde filteren. filteredAnnotations = filteredAnnotations.filter(a => !isAnnotationStatusHidden(a)); + // Filterbalk van het overzicht (#383): auteur, soort, paginabereik en + // datumbereik, over alle pagina's. De keuzes komen uit het hele document, + // zodat een gekozen auteur in de lijst blijft staan als hij wegvalt. + setFilterOptions({ + authors: auteursIn(annotations), + types: typesIn(annotations, getTypeDisplayName), + }); + filteredAnnotations = filterOpmerkingen(filteredAnnotations, listFilter()); + // Proefleescorrecties (#508): de doorhaling van een vervanging staat in de // regel van haar invoegteken, met de naam en voorvertoning van de correctie. const byId = new Map(annotations.map(a => [a.id, a])); @@ -129,79 +142,9 @@ export function updateAnnotationsList(filterValue) { // Sort and group based on current sort mode const currentSort = sortMode(); - const pageThenDate = (a, b) => { - if (a.page !== b.page) return a.page - b.page; - return new Date(a.createdAt) - new Date(b.createdAt); - }; - - if (currentSort === 'type') { - filteredAnnotations.sort((a, b) => { - const ta = getTypeDisplayName(a.type); - const tb = getTypeDisplayName(b.type); - if (ta !== tb) return ta.localeCompare(tb); - return pageThenDate(a, b); - }); - } else if (currentSort === 'modifiedDate') { - filteredAnnotations.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)); - } else if (currentSort === 'creationDate') { - filteredAnnotations.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - } else if (currentSort === 'author') { - filteredAnnotations.sort((a, b) => { - const aa = (a.author || 'User').toLowerCase(); - const ab = (b.author || 'User').toLowerCase(); - if (aa !== ab) return aa.localeCompare(ab); - return pageThenDate(a, b); - }); - } else if (currentSort === 'color') { - filteredAnnotations.sort((a, b) => { - const ca = (a.color || a.strokeColor || '#000').toLowerCase(); - const cb = (b.color || b.strokeColor || '#000').toLowerCase(); - if (ca !== cb) return ca.localeCompare(cb); - return pageThenDate(a, b); - }); - } else if (currentSort === 'subject') { - filteredAnnotations.sort((a, b) => { - const sa = (a.subject || '').toLowerCase(); - const sb = (b.subject || '').toLowerCase(); - if (sa !== sb) return sa.localeCompare(sb); - return pageThenDate(a, b); - }); - } else if (currentSort === 'status') { - filteredAnnotations.sort((a, b) => { - const sa = (a.status || 'none').toLowerCase(); - const sb = (b.status || 'none').toLowerCase(); - if (sa !== sb) return sa.localeCompare(sb); - return pageThenDate(a, b); - }); - } else if (currentSort === 'statusAndAuthor') { - filteredAnnotations.sort((a, b) => { - const sa = (a.status || 'none').toLowerCase(); - const sb = (b.status || 'none').toLowerCase(); - if (sa !== sb) return sa.localeCompare(sb); - const aa = (a.author || 'User').toLowerCase(); - const ab = (b.author || 'User').toLowerCase(); - if (aa !== ab) return aa.localeCompare(ab); - return pageThenDate(a, b); - }); - } else if (currentSort === 'lastStatusAuthor') { - const getLastStatusAuthor = (ann) => { - if (ann.replies && ann.replies.length > 0) { - for (let i = ann.replies.length - 1; i >= 0; i--) { - if (ann.replies[i].author) return ann.replies[i].author; - } - } - return ann.author || 'User'; - }; - filteredAnnotations.sort((a, b) => { - const aa = getLastStatusAuthor(a).toLowerCase(); - const ab = getLastStatusAuthor(b).toLowerCase(); - if (aa !== ab) return aa.localeCompare(ab); - return pageThenDate(a, b); - }); - } else { - // Default: sort by page - filteredAnnotations.sort(pageThenDate); - } + // De volgorde rekent op één plek (opmerkingen-overzicht.js), zodat lijst, + // CSV en rapport dezelfde volgorde hebben. + filteredAnnotations = sorteerOpmerkingen(filteredAnnotations, currentSort, { typeNaam: getTypeDisplayName }); if (filteredAnnotations.length === 0) { setEmptyMessage('No annotations found'); @@ -225,15 +168,7 @@ export function updateAnnotationsList(filterValue) { return row; }; - // Helper to get last status author - const getLastStatusAuthor = (ann) => { - if (ann.replies && ann.replies.length > 0) { - for (let i = ann.replies.length - 1; i >= 0; i--) { - if (ann.replies[i].author) return ann.replies[i].author; - } - } - return ann.author || 'User'; - }; + const getLastStatusAuthor = laatsteStatusAuteur; // Helper to capitalize status const capitalize = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s; diff --git a/open-pdf-studio/js/ui/panels/annotations-summary.js b/open-pdf-studio/js/ui/panels/annotations-summary.js new file mode 100644 index 000000000..b1b20456d --- /dev/null +++ b/open-pdf-studio/js/ui/panels/annotations-summary.js @@ -0,0 +1,180 @@ +// "Opmerkingen samenvatten" in het opmerkingenpaneel (#383): de opmerkingen +// zoals de lijst ze toont (filter en volgorde) als PDF-rapport, elk met een +// uitsnede van haar plek op de pagina, en de status van veel opmerkingen +// tegelijk zetten. +// +// Het rekenwerk zit in annotations/opmerkingen-overzicht.js en +// annotations/opmerkingen-rapport.js; hier alleen het renderen van de +// uitsneden, de vertalingen en de bestandsdialoog. + +import { state, getActiveDocument, getAnnotationBounds } from '../../core/state.js'; +import i18next from '../../i18n/config.js'; +import { showLoading, hideLoading } from '../chrome/dialogs.js'; +import { updateStatusMessage } from '../chrome/status-bar.js'; +import { saveFileDialog, writeBinaryFile, extractFileName } from '../../core/platform.js'; +import { getAnnotationDisplayName } from '../../utils/helpers.js'; +import { renderPageOffscreen, canvasToBytes } from '../../pdf/exporter.js'; +import { csvDatum, inhoudVan } from '../../annotations/opmerkingen-export.js'; +import { uitsnedeVoor, statusDoelen, zetStatus } from '../../annotations/opmerkingen-overzicht.js'; +import { bouwOpmerkingenRapport } from '../../annotations/opmerkingen-rapport.js'; +import { recordBulkModify } from '../../core/undo-manager.js'; +import { cloneAnnotation } from '../../annotations/factory.js'; +import { StandardFonts } from 'pdf-lib'; + +const tp = (key, opties) => i18next.t(key, { ns: 'properties', ...(opties || {}) }); + +export function statusLabel(status) { + const s = String(status || 'none'); + const hoofd = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); + return i18next.t(`annotation.status${hoofd}`, { ns: 'context', defaultValue: hoofd }); +} + +async function paginaMaat(doc, pageNum, cache) { + if (cache.has(pageNum)) return cache.get(pageNum); + const page = await doc.pdfDoc.getPage(pageNum); + const extra = doc.pageRotations?.[pageNum] || 0; + const vp = page.getViewport(extra ? { scale: 1, rotation: (page.rotate + extra) % 360 } : { scale: 1 }); + const maat = { breedte: vp.width, hoogte: vp.height }; + cache.set(pageNum, maat); + return maat; +} + +async function uitsnedeVan(doc, ann, cache) { + const bounds = getAnnotationBounds(ann); + const maat = await paginaMaat(doc, ann.page, cache); + const gebied = uitsnedeVoor(bounds, maat); + if (!gebied) return null; + const canvas = await renderPageOffscreen(ann.page, gebied.schaal, { deel: gebied.deel }); + try { + // Een kader om de opmerking, zodat in de uitsnede te zien is welke het is. + const ctx = canvas.getContext('2d'); + const k = gebied.kader; + ctx.save(); + ctx.strokeStyle = '#e81123'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 3]); + ctx.strokeRect(k.x - 3, k.y - 3, k.breedte + 6, k.hoogte + 6); + ctx.restore(); + const bytes = await canvasToBytes(canvas, 'jpeg', 0.85); + return { bytes, soort: 'jpeg' }; + } finally { + canvas.width = 0; + canvas.height = 0; + } +} + +// Helvetica waar dat kan; tekst die Helvetica niet kan tonen (Grieks, Cyrillisch, +// Chinees, ...) krijgt het gebundelde Noto-font als het er is. +function maakFontKeuze(teksten) { + return async (pdf) => { + const normaal = await pdf.embedFont(StandardFonts.Helvetica); + const vet = await pdf.embedFont(StandardFonts.HelveticaBold); + const set = new Set(normaal.getCharacterSet()); + const allesLatijn = teksten.every((t) => [...String(t || '')].every((c) => c === '\n' || c === '\r' || c === '\t' || set.has(c.codePointAt(0)))); + if (allesLatijn) return { normaal, vet }; + try { + const { embedOcrFont, loadDefaultOcrFontBytes } = await import('../../pdf/saver/ocr-text-layer.js'); + const font = await embedOcrFont(pdf, await loadDefaultOcrFontBytes()); + return { normaal: font, vet: font }; + } catch { + return { normaal, vet }; + } + }; +} + +/** + * Het rapport van de gegeven opmerkingen (in deze volgorde) bouwen en + * opslaan. Geeft true als het bestand geschreven is. + */ +export async function exportAnnotationsSummaryPdf(annotations) { + const doc = getActiveDocument(); + if (!doc?.pdfDoc || !annotations?.length) return false; + const byId = new Map(doc.annotations.map((a) => [a.id, a])); + const documentNaam = doc.filePath ? extractFileName(doc.filePath) : (doc.fileName || ''); + const baseName = doc.filePath ? doc.filePath.replace(/\.pdf$/i, '') : 'annotations'; + const pad = await saveFileDialog(baseName + '-summary.pdf', [{ name: 'PDF', extensions: ['pdf'] }]); + if (!pad) return false; + + const bericht = tp('leftPanel.summaryBusy'); + showLoading(bericht, { voortgang: 0 }); + try { + const cache = new Map(); + const ingangen = []; + for (let i = 0; i < annotations.length; i++) { + const ann = annotations[i]; + if (getActiveDocument() !== doc) throw new Error(tp('leftPanel.summaryFailed')); + let uitsnede = null; + try { + uitsnede = await uitsnedeVan(doc, ann, cache); + } catch (err) { + console.warn(`[Summary] crop failed for ${ann.id}:`, err); + } + const status = ann.status && String(ann.status).toLowerCase() !== 'none' ? statusLabel(ann.status) : ''; + ingangen.push({ + pagina: ann.page, + type: getAnnotationDisplayName(ann, byId), + auteur: ann.author || 'User', + datum: csvDatum(ann.modifiedAt || ann.createdAt), + status, + onderwerp: ann.subject || '', + inhoud: inhoudVan(ann), + reacties: (ann.replies || []).filter(Boolean).map((r) => ({ + auteur: r.author || 'User', datum: csvDatum(r.createdAt), tekst: r.text || '', + })), + uitsnede, + }); + showLoading(bericht, { voortgang: (i + 1) / annotations.length }); + } + const labels = { + titel: tp('leftPanel.summaryTitle'), + document: tp('leftPanel.summaryDocument'), + gemaakt: tp('leftPanel.summaryCreated'), + aantal: tp('leftPanel.summaryCount'), + pagina: i18next.t('page', { ns: 'common' }), + auteur: tp('leftPanel.csvAuthor'), + datum: tp('leftPanel.csvModified'), + status: tp('leftPanel.csvStatus'), + onderwerp: tp('leftPanel.csvSubject'), + reacties: tp('leftPanel.csvReplies'), + geenTekst: tp('leftPanel.summaryNoText'), + }; + const teksten = [documentNaam, ...Object.values(labels)]; + for (const ing of ingangen) { + teksten.push(ing.type, ing.auteur, ing.status, ing.onderwerp, ing.inhoud); + for (const r of ing.reacties) teksten.push(r.auteur, r.tekst); + } + const { bytes } = await bouwOpmerkingenRapport({ + documentNaam, + gemaakt: csvDatum(new Date()), + labels, + ingangen, + kiesFonts: maakFontKeuze(teksten), + }); + await writeBinaryFile(pad, bytes); + updateStatusMessage(tp('leftPanel.summaryExported')); + return true; + } catch (err) { + console.error('[Summary] export failed:', err); + updateStatusMessage(tp('leftPanel.summaryFailed')); + return false; + } finally { + hideLoading(); + } +} + +/** + * Zet de status van de gegeven opmerkingen in één ongedaan-stap. Vergrendelde + * opmerkingen en opmerkingen die de status al hebben blijven ongemoeid. + * @returns {number} hoeveel opmerkingen er veranderd zijn + */ +export function setStatusOfAnnotations(annotations, status) { + const doelen = statusDoelen(annotations, status); + if (doelen.length === 0) return 0; + const originelen = doelen.map((a) => cloneAnnotation(a)); + const nu = new Date().toISOString(); + const door = state.defaultAuthor || 'User'; + for (const ann of doelen) zetStatus(ann, status, { door, nu }); + recordBulkModify(doelen, originelen); + updateStatusMessage(tp('leftPanel.statusChanged', { count: doelen.length })); + return doelen.length; +} diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index d65cf2e47..25479a630 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -396,6 +396,8 @@ js/mobile/ipc-wekker.test.mjs js/ui/ui-lettertype.test.mjs js/compare/split-line.test.mjs js/annotations/opmerkingen-export.test.mjs +js/annotations/opmerkingen-overzicht.test.mjs +js/annotations/opmerkingen-rapport.test.mjs js/annotations/xfdf-pagina.test.mjs js/compare/change-detect-worker.test.mjs js/pdf/thin-lines.test.mjs diff --git a/open-pdf-studio/styles/panels.css b/open-pdf-studio/styles/panels.css index 6389110d3..2270ec6e3 100644 --- a/open-pdf-studio/styles/panels.css +++ b/open-pdf-studio/styles/panels.css @@ -167,6 +167,69 @@ display: block; } +/* Filterbalk van het opmerkingenoverzicht (#383) */ +.annotations-filter-bar { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 3px 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--theme-panel-border); + background: var(--theme-panel-tab-bg); + font-size: 11px; + color: var(--theme-panel-tab-text); + flex-shrink: 0; +} + +.annotations-filter-bar select, +.annotations-filter-bar input { + min-width: 0; + width: 100%; + height: 22px; + font-size: 11px; + border: 1px solid var(--theme-border, #c0c0c0); + border-radius: 0; + background: var(--theme-input-bg, #ffffff); + color: var(--theme-text, #333); + padding: 0 3px; + box-sizing: border-box; +} + +.annotations-filter-range { + display: flex; + align-items: center; + gap: 3px; + min-width: 0; +} + +.annotations-filter-range input { + flex: 1; +} + +.annotations-filter-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; +} + +.annotations-filter-clear { + height: 22px; + font-size: 11px; + padding: 0 10px; + border: 1px solid var(--theme-border, #c0c0c0); + border-radius: 0; + background: var(--theme-surface, #f0f0f0); + color: var(--theme-text, #333); +} + +.annotations-filter-clear:hover:not(:disabled) { + background: var(--theme-hover, #e5f1fb); +} + +.annotations-filter-clear:disabled { + color: #aaa; +} + .annotations-list-content { flex: 1; overflow-y: auto; From 9cb819f27c600ba7b24ab20b1d91150b220ca0a0 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 10:00:35 +0100 Subject: [PATCH 03/13] feat(layers): layer tree, annotation layers and saved default state (#775) The Layers panel follows the /OCProperties /D tree from the file: nested layers, labels, /Locked layers (shown with a lock, checkbox disabled) and /RBGroups (radio behaviour). "Set as default" stores the toggled state, and the next save writes it as /D /ON /OFF. The toggled state also applies to raster export, printing and the vector "save as PDF" print. The properties panel gets a "Layer" field: a markup can be put on a markup layer or on a layer of the drawing, in which case it gets /OC to that OCG on save, loads back on it and hides and shows with it. Fixes #377 Review fixes on top: - An adopted drawing layer whose markup layer was removed while other markup layers remained was dropped from /OCGs, /Order, /Locked, /RBGroups and /ON /OFF. Every adopted OCG known from the file now keeps its place; only the app's own keys are removed. - The drawing layer's /Name is no longer overwritten by the markup layer name (for example a " (2)" suffix or a rename in the panel). - Adopting a layer no longer turns the current view state into the file's default. Adopted layers keep their /D state; only "Set as default" changes it (laagstandZonderEigenLagen). - Regression tests for all three cases (save, reopen, check /OCGs, /Order, /RBGroups, /Locked, name and default state). --- open-pdf-studio/js/annotations/laag-keuze.js | 52 ++++ .../js/annotations/laag-keuze.test.mjs | 101 ++++++++ open-pdf-studio/js/bridge.ts | 2 + .../js/i18n/locales/ar/properties.json | 12 +- .../js/i18n/locales/bg/properties.json | 12 +- .../js/i18n/locales/bn/properties.json | 12 +- .../js/i18n/locales/ca/properties.json | 12 +- .../js/i18n/locales/cs/properties.json | 12 +- .../js/i18n/locales/da/properties.json | 12 +- .../js/i18n/locales/de/properties.json | 12 +- .../js/i18n/locales/el/properties.json | 12 +- .../js/i18n/locales/en/properties.json | 12 +- .../js/i18n/locales/es/properties.json | 12 +- .../js/i18n/locales/fa/properties.json | 12 +- .../js/i18n/locales/fi/properties.json | 12 +- .../js/i18n/locales/fr/properties.json | 12 +- .../js/i18n/locales/he/properties.json | 12 +- .../js/i18n/locales/hi/properties.json | 12 +- .../js/i18n/locales/hr/properties.json | 12 +- .../js/i18n/locales/hu/properties.json | 12 +- .../js/i18n/locales/id/properties.json | 12 +- .../js/i18n/locales/it/properties.json | 12 +- .../js/i18n/locales/ja/properties.json | 12 +- .../js/i18n/locales/ko/properties.json | 12 +- .../js/i18n/locales/lo/properties.json | 12 +- .../js/i18n/locales/ms/properties.json | 12 +- .../js/i18n/locales/nb/properties.json | 12 +- .../js/i18n/locales/nl/properties.json | 12 +- .../js/i18n/locales/pl/properties.json | 12 +- .../js/i18n/locales/pt/properties.json | 12 +- .../js/i18n/locales/ro/properties.json | 12 +- .../js/i18n/locales/ru/properties.json | 12 +- .../js/i18n/locales/sk/properties.json | 12 +- .../js/i18n/locales/sr/properties.json | 12 +- .../js/i18n/locales/sv/properties.json | 12 +- .../js/i18n/locales/sw/properties.json | 12 +- .../js/i18n/locales/ta/properties.json | 12 +- .../js/i18n/locales/th/properties.json | 12 +- .../js/i18n/locales/tr/properties.json | 12 +- .../js/i18n/locales/uk/properties.json | 12 +- .../js/i18n/locales/ur/properties.json | 12 +- .../js/i18n/locales/vi/properties.json | 12 +- .../js/i18n/locales/zh/properties.json | 12 +- open-pdf-studio/js/pdf/exporter.js | 5 +- open-pdf-studio/js/pdf/laag-ids.js | 27 ++ open-pdf-studio/js/pdf/laagstand.test.mjs | 3 +- open-pdf-studio/js/pdf/lagen-boom.js | 206 +++++++++++++++ open-pdf-studio/js/pdf/lagen-boom.test.mjs | 206 +++++++++++++++ open-pdf-studio/js/pdf/lagen-render.test.mjs | 243 ++++++++++++++++++ open-pdf-studio/js/pdf/lagen-schakelen.js | 144 ++++++++++- open-pdf-studio/js/pdf/print-job.js | 3 +- open-pdf-studio/js/pdf/save-document.test.mjs | 6 +- open-pdf-studio/js/pdf/saver.js | 18 +- .../saver/annotatie-lagen-tekening.test.mjs | 219 ++++++++++++++++ .../js/pdf/saver/annotatie-lagen.js | 122 +++++++-- .../left-panel/panels/LayersPanel.jsx | 39 ++- .../properties-panel/GeneralSection.jsx | 5 + .../properties-panel/LayerField.jsx | 78 ++++++ .../js/solid/stores/annotationLayersStore.js | 40 ++- .../js/solid/stores/panels/layersStore.js | 6 + .../js/solid/stores/propertiesStore.js | 25 ++ open-pdf-studio/js/ui/panels/lagen-lijst.js | 60 ++++- open-pdf-studio/js/ui/panels/layers.js | 111 ++++++-- open-pdf-studio/scripts/unit-testlijst.txt | 4 + open-pdf-studio/styles/panels.css | 49 ++++ 65 files changed, 2125 insertions(+), 129 deletions(-) create mode 100644 open-pdf-studio/js/annotations/laag-keuze.js create mode 100644 open-pdf-studio/js/annotations/laag-keuze.test.mjs create mode 100644 open-pdf-studio/js/pdf/laag-ids.js create mode 100644 open-pdf-studio/js/pdf/lagen-boom.js create mode 100644 open-pdf-studio/js/pdf/lagen-boom.test.mjs create mode 100644 open-pdf-studio/js/pdf/lagen-render.test.mjs create mode 100644 open-pdf-studio/js/pdf/saver/annotatie-lagen-tekening.test.mjs create mode 100644 open-pdf-studio/js/solid/components/properties-panel/LayerField.jsx diff --git a/open-pdf-studio/js/annotations/laag-keuze.js b/open-pdf-studio/js/annotations/laag-keuze.js new file mode 100644 index 000000000..fe1d20d85 --- /dev/null +++ b/open-pdf-studio/js/annotations/laag-keuze.js @@ -0,0 +1,52 @@ +// De keuzelijst "Laag" in het eigenschappenpaneel (#377): één lijst met de +// markeringslagen van de app (#468) en de lagen van de tekening (OCG's van +// het document). Een waarde is 'a:' voor een markeringslaag of +// 'o:' voor een laag van de tekening. Een markering op een laag +// van de tekening heeft als `layer` 'ocg--' (zie +// saver/annotatie-lagen.js); in de lijst staat die bij de tekening. +// +// Puur, getest in laag-keuze.test.mjs. + +import { DEFAULT_LAYER_ID, getLayers, layerDisplayName, layerIdOf } from './annotatie-lagen.js'; +import { ocgVoorAnnotatieLaagId } from '../pdf/laag-ids.js'; + +/** De waarde in de keuzelijst voor de laag van deze annotatie. */ +export function laagKeuzeWaarde(ann, doc = null) { + const id = layerIdOf(ann); + const ocg = ocgVoorAnnotatieLaagId(id); + if (ocg) return `o:${ocg}`; + // Een laag die het document niet kent, valt terug op de standaardlaag. + if (doc && !getLayers(doc).some((l) => l.id === id)) return `a:${DEFAULT_LAYER_ID}`; + return `a:${id}`; +} + +/** 'a:x' → {soort:'annotatie', id:'x'}; 'o:12R' → {soort:'tekening', id:'12R'}; anders null. */ +export function leesLaagKeuze(waarde) { + const m = /^([ao]):(.+)$/.exec(String(waarde || '')); + if (!m) return null; + return { soort: m[1] === 'a' ? 'annotatie' : 'tekening', id: m[2] }; +} + +/** + * De opties van de keuzelijst. + * @param {object} doc het document (annotationLayers) + * @param {{id: string, name: string}[]} tekeningLagen de lagen van de tekening + * (de regels van het paneel Lagen zonder koppen) + * @param {string} standaardNaam de weergavenaam van de standaardlaag + * @returns {{markeringen: {waarde: string, naam: string}[], tekening: {waarde: string, naam: string}[]}} + */ +export function laagKeuzeOpties(doc, tekeningLagen, standaardNaam) { + const tekening = (tekeningLagen || []).map((l) => ({ waarde: `o:${l.id}`, naam: l.name })); + const inTekening = new Set(tekening.map((t) => t.waarde)); + const markeringen = []; + for (const laag of getLayers(doc)) { + const ocg = ocgVoorAnnotatieLaagId(laag.id); + if (ocg) { + // Een overgenomen laag die (nog) niet bij de tekening staat: bij die groep. + if (!inTekening.has(`o:${ocg}`)) tekening.push({ waarde: `o:${ocg}`, naam: laag.name }); + continue; + } + markeringen.push({ waarde: `a:${laag.id}`, naam: layerDisplayName(laag, standaardNaam) }); + } + return { markeringen, tekening }; +} diff --git a/open-pdf-studio/js/annotations/laag-keuze.test.mjs b/open-pdf-studio/js/annotations/laag-keuze.test.mjs new file mode 100644 index 000000000..23d404fe1 --- /dev/null +++ b/open-pdf-studio/js/annotations/laag-keuze.test.mjs @@ -0,0 +1,101 @@ +// De keuzelijst "Laag" in het eigenschappenpaneel (#377), en de bedrading +// van de laagstand naar opslaan, afdrukken en exporteren. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { laagKeuzeWaarde, leesLaagKeuze, laagKeuzeOpties } from './laag-keuze.js'; +import { addLayer, ensureLayers, assignLayer, layerIdOf, isLayerHidden, updateLayer } from './annotatie-lagen.js'; + +function document() { + const doc = { annotations: [] }; + ensureLayers(doc); + const opm = addLayer(doc, { name: 'Opmerkingen' }).layer; + addLayer(doc, { id: 'ocg-12-0', name: 'Wanden' }); + return { doc, opm }; +} + +test('de waarde van een annotatie: markeringslaag, laag van de tekening of standaard', () => { + const { doc, opm } = document(); + assert.equal(laagKeuzeWaarde({}, doc), 'a:default'); + assert.equal(laagKeuzeWaarde({ layer: opm.id }, doc), `a:${opm.id}`); + assert.equal(laagKeuzeWaarde({ layer: 'ocg-12-0' }, doc), 'o:12R'); + assert.equal(laagKeuzeWaarde({ layer: 'ocg-12-3' }), 'o:12R3'); + assert.equal(laagKeuzeWaarde({ layer: 'weg' }, doc), 'a:default', 'onbekende laag: standaard'); +}); + +test('een keuze lezen', () => { + assert.deepEqual(leesLaagKeuze('a:default'), { soort: 'annotatie', id: 'default' }); + assert.deepEqual(leesLaagKeuze('o:12R'), { soort: 'tekening', id: '12R' }); + assert.equal(leesLaagKeuze('mixed'), null); + assert.equal(leesLaagKeuze(''), null); +}); + +test('de opties: markeringslagen apart, lagen van de tekening met de overgenomen laag erbij', () => { + const { doc, opm } = document(); + const { markeringen, tekening } = laagKeuzeOpties(doc, [{ id: '7R', name: 'Stramien' }], 'Standaard'); + assert.deepEqual(markeringen, [ + { waarde: 'a:default', naam: 'Standaard' }, + { waarde: `a:${opm.id}`, naam: 'Opmerkingen' }, + ]); + assert.deepEqual(tekening, [ + { waarde: 'o:7R', naam: 'Stramien' }, + { waarde: 'o:12R', naam: 'Wanden' }, + ]); + // Staat de laag al bij de tekening, dan niet nog eens. + const twee = laagKeuzeOpties(doc, [{ id: '12R', name: 'Wanden' }], 'Standaard'); + assert.deepEqual(twee.tekening, [{ waarde: 'o:12R', naam: 'Wanden' }]); +}); + +test('een annotatie op een laag van de tekening verdwijnt als die laag uit gaat', () => { + const { doc } = document(); + const ann = { id: 'x' }; + doc.annotations.push(ann); + assignLayer([ann], 'ocg-12-0'); + assert.equal(layerIdOf(ann), 'ocg-12-0'); + assert.equal(isLayerHidden(doc, ann), false); + // Zoals ui/panels/layers.js (volgAnnotatieLagen) na het schakelen in het paneel Lagen. + updateLayer(doc, 'ocg-12-0', { visible: false }); + assert.equal(isLayerHidden(doc, ann), true); +}); + +const lees = (pad) => readFileSync(new URL(pad, import.meta.url), 'utf8'); + +test('bedrading: opslaan, afdrukken, exporteren en het paneel volgen de laagstand', () => { + const saver = lees('../pdf/saver.js'); + assert.match(saver, /const laagStandaard = standaardOmTeBewaren\(doc\);/); + assert.match(saver, /schrijfStandaardLaagstand\(pdfDocLib, laagStandaard\)/); + assert.match(saver, /lagenNaOpslaan\(activeDoc, \{ standBewaard: !!laagStandaard \}\)/); + assert.match(lees('../pdf/exporter.js'), /\.\.\.pdfjsLaagOpties\(doc\)/); + assert.match(lees('../pdf/print-job.js'), /bytesMetLaagstand\(doc, await bronBytesVan\(doc\)\)/); + const lagen = lees('../ui/panels/layers.js'); + assert.match(lagen, /lagenLijst\(ocConfig, \{ overslaan: [^}]*boom \}\)/); + assert.match(lagen, /if \(boom\?\.locked\?\.has\(id\)\)/); + assert.match(lagen, /volgAnnotatieLagen\(activeDoc, eigenLaagConfig\(activeDoc\)\)/); + const paneel = lees('../solid/components/left-panel/panels/LayersPanel.jsx'); + assert.match(paneel, /m\.setLayerStateAsDefault\(\)/); + const eigenschappen = lees('../solid/components/properties-panel/GeneralSection.jsx'); + assert.match(eigenschappen, //); +}); + +test('vertalingen: elke taal heeft de nieuwe sleutels, en niet de Engelse tekst', async () => { + const { readdirSync } = await import('node:fs'); + const talen = readdirSync(new URL('../i18n/locales/', import.meta.url)); + const en = JSON.parse(lees('../i18n/locales/en/properties.json')); + const sleutels = [ + ['general', 'layer'], ['general', 'layerMarkupGroup'], ['general', 'layerDocumentGroup'], + ['leftPanel', 'layersSetDefault'], ['leftPanel', 'layersSetDefaultTitle'], ['leftPanel', 'layersDefaultPending'], + ['leftPanel', 'layerLocked'], ['leftPanel', 'layerRadioGroup'], + ]; + assert.ok(talen.length >= 37); + for (const taal of talen) { + const d = JSON.parse(lees(`../i18n/locales/${taal}/properties.json`)); + for (const [groep, sleutel] of sleutels) { + const tekst = d[groep]?.[sleutel]; + assert.equal(typeof tekst, 'string', `${taal}: ${groep}.${sleutel}`); + assert.ok(tekst.length > 0); + if (taal !== 'en' && sleutel === 'layersSetDefaultTitle') assert.notEqual(tekst, en[groep][sleutel], `${taal}: vertaald`); + } + } +}); diff --git a/open-pdf-studio/js/bridge.ts b/open-pdf-studio/js/bridge.ts index bd27a278f..7fe423e0c 100644 --- a/open-pdf-studio/js/bridge.ts +++ b/open-pdf-studio/js/bridge.ts @@ -274,6 +274,8 @@ export { setItems as setLayerItems, setCountText as setLayerCountText, setEmptyMessage as setLayerEmptyMessage, + setCanSetDefault as setLayerCanSetDefault, + setDefaultPending as setLayerDefaultPending, } from './solid/stores/panels/layersStore.js'; export { diff --git a/open-pdf-studio/js/i18n/locales/ar/properties.json b/open-pdf-studio/js/i18n/locales/ar/properties.json index 2c6288c84..2d9606472 100644 --- a/open-pdf-studio/js/i18n/locales/ar/properties.json +++ b/open-pdf-studio/js/i18n/locales/ar/properties.json @@ -41,7 +41,10 @@ "cancelled": "ملغي", "completed": "مكتمل", "reviewed": "تمت المراجعة", - "statusSetBy": "عيّنها {{author}} في {{date}}" + "statusSetBy": "عيّنها {{author}} في {{date}}", + "layer": "الطبقة", + "layerMarkupGroup": "طبقات العلامات", + "layerDocumentGroup": "طبقات المستند" }, "colorNone": "بدون", "moreColors": "ألوان أخرى...", @@ -421,7 +424,12 @@ "addPage": "إضافة صفحة", "pageLabels": "تسميات الصفحات (منشئ الأسماء)", "portfolio": "حافظة PDF", - "recognizeForm": "التعرف على حقول النموذج" + "recognizeForm": "التعرف على حقول النموذج", + "layersSetDefault": "تعيين كافتراضي", + "layersSetDefaultTitle": "حفظ ظهور الطبقات الحالي كحالة افتراضية للمستند. يُكتب في الملف عند الحفظ التالي.", + "layersDefaultPending": "ستصبح الافتراضية عند الحفظ", + "layerLocked": "طبقة مقفلة: لا يمكن تشغيلها أو إيقافها", + "layerRadioGroup": "مجموعة اختيار: تشغيل هذه الطبقة يوقف الطبقات الأخرى في المجموعة" }, "scaleBar": { "title": "شريط المقياس", diff --git a/open-pdf-studio/js/i18n/locales/bg/properties.json b/open-pdf-studio/js/i18n/locales/bg/properties.json index 6eedeb08a..d7dc6d6bd 100644 --- a/open-pdf-studio/js/i18n/locales/bg/properties.json +++ b/open-pdf-studio/js/i18n/locales/bg/properties.json @@ -41,7 +41,10 @@ "cancelled": "Отменено", "completed": "Завършено", "reviewed": "Прегледано", - "statusSetBy": "Зададено от {{author}} на {{date}}" + "statusSetBy": "Зададено от {{author}} на {{date}}", + "layer": "Слой", + "layerMarkupGroup": "Слоеве с маркировки", + "layerDocumentGroup": "Слоеве на документа" }, "colorNone": "Няма", "moreColors": "Повече цветове...", @@ -421,7 +424,12 @@ "addPage": "Добавяне на страница", "pageLabels": "Етикети на страници (създаване на имена)", "portfolio": "PDF портфолио", - "recognizeForm": "Разпознаване на полета на формуляр" + "recognizeForm": "Разпознаване на полета на формуляр", + "layersSetDefault": "Задай по подразбиране", + "layersSetDefaultTitle": "Запазване на текущата видимост на слоевете като състояние по подразбиране на документа. Записва се във файла при следващото запазване.", + "layersDefaultPending": "Става по подразбиране при запазване", + "layerLocked": "Заключен слой: не може да се включва или изключва", + "layerRadioGroup": "Група за избор: включването на този слой изключва другите слоеве в групата" }, "scaleBar": { "title": "Линеен мащаб", diff --git a/open-pdf-studio/js/i18n/locales/bn/properties.json b/open-pdf-studio/js/i18n/locales/bn/properties.json index 0db40a672..dbba9581c 100644 --- a/open-pdf-studio/js/i18n/locales/bn/properties.json +++ b/open-pdf-studio/js/i18n/locales/bn/properties.json @@ -41,7 +41,10 @@ "cancelled": "বাতিল করা হয়েছে", "completed": "সম্পন্ন", "reviewed": "পর্যালোচিত", - "statusSetBy": "{{date}} তারিখে {{author}} নির্ধারণ করেছেন" + "statusSetBy": "{{date}} তারিখে {{author}} নির্ধারণ করেছেন", + "layer": "স্তর", + "layerMarkupGroup": "মার্কআপ স্তর", + "layerDocumentGroup": "নথির স্তর" }, "colorNone": "কোনোটি নয়", "moreColors": "আরও রঙ...", @@ -421,7 +424,12 @@ "addPage": "পৃষ্ঠা যোগ করুন", "pageLabels": "পৃষ্ঠা লেবেল (নাম নির্মাতা)", "portfolio": "PDF পোর্টফোলিও", - "recognizeForm": "ফর্ম ক্ষেত্র শনাক্ত করুন" + "recognizeForm": "ফর্ম ক্ষেত্র শনাক্ত করুন", + "layersSetDefault": "ডিফল্ট হিসেবে সেট করুন", + "layersSetDefaultTitle": "স্তরগুলোর বর্তমান দৃশ্যমানতা নথির ডিফল্ট অবস্থা হিসেবে রাখুন। পরের বার সংরক্ষণের সময় এটি ফাইলে লেখা হয়।", + "layersDefaultPending": "সংরক্ষণে ডিফল্ট হবে", + "layerLocked": "লক করা স্তর: এটি চালু বা বন্ধ করা যায় না", + "layerRadioGroup": "পছন্দ গোষ্ঠী: এই স্তর চালু করলে গোষ্ঠীর অন্য স্তরগুলো বন্ধ হয়ে যায়" }, "scaleBar": { "title": "স্কেল বার", diff --git a/open-pdf-studio/js/i18n/locales/ca/properties.json b/open-pdf-studio/js/i18n/locales/ca/properties.json index baa671242..92582a4cb 100644 --- a/open-pdf-studio/js/i18n/locales/ca/properties.json +++ b/open-pdf-studio/js/i18n/locales/ca/properties.json @@ -41,7 +41,10 @@ "cancelled": "Cancel·lat", "completed": "Completat", "reviewed": "Revisat", - "statusSetBy": "Definit per {{author}} el {{date}}" + "statusSetBy": "Definit per {{author}} el {{date}}", + "layer": "Capa", + "layerMarkupGroup": "Capes de marques", + "layerDocumentGroup": "Capes del document" }, "colorNone": "Cap", "moreColors": "Més colors...", @@ -427,7 +430,12 @@ "addPage": "Afegeix una pàgina", "pageLabels": "Etiquetes de pàgina (generador de noms)", "portfolio": "Portafolis PDF", - "recognizeForm": "Reconeix camps de formulari" + "recognizeForm": "Reconeix camps de formulari", + "layersSetDefault": "Estableix com a predeterminat", + "layersSetDefaultTitle": "Desa la visibilitat actual de les capes com a estat predeterminat del document. S'escriu al fitxer la propera vegada que deseu.", + "layersDefaultPending": "Serà el predeterminat en desar", + "layerLocked": "Capa bloquejada: no es pot activar ni desactivar", + "layerRadioGroup": "Grup d'opcions: activar aquesta capa desactiva les altres capes del grup" }, "scaleBar": { "title": "Barra d'escala", diff --git a/open-pdf-studio/js/i18n/locales/cs/properties.json b/open-pdf-studio/js/i18n/locales/cs/properties.json index 0a53ae706..e1f8c18e8 100644 --- a/open-pdf-studio/js/i18n/locales/cs/properties.json +++ b/open-pdf-studio/js/i18n/locales/cs/properties.json @@ -41,7 +41,10 @@ "cancelled": "Zrušeno", "completed": "Dokončeno", "reviewed": "Zkontrolováno", - "statusSetBy": "Nastavil(a) {{author}} dne {{date}}" + "statusSetBy": "Nastavil(a) {{author}} dne {{date}}", + "layer": "Vrstva", + "layerMarkupGroup": "Vrstvy značek", + "layerDocumentGroup": "Vrstvy dokumentu" }, "colorNone": "Žádná", "moreColors": "Další barvy...", @@ -421,7 +424,12 @@ "addPage": "Přidat stránku", "pageLabels": "Popisky stránek (tvorba názvů)", "portfolio": "Portfolio PDF", - "recognizeForm": "Rozpoznat pole formuláře" + "recognizeForm": "Rozpoznat pole formuláře", + "layersSetDefault": "Nastavit jako výchozí", + "layersSetDefaultTitle": "Uložit aktuální viditelnost vrstev jako výchozí stav dokumentu. Zapíše se do souboru při příštím uložení.", + "layersDefaultPending": "Při uložení se stane výchozím", + "layerLocked": "Uzamčená vrstva: nelze ji zapnout ani vypnout", + "layerRadioGroup": "Skupina voleb: zapnutím této vrstvy se ostatní vrstvy skupiny vypnou" }, "scaleBar": { "title": "Grafické měřítko", diff --git a/open-pdf-studio/js/i18n/locales/da/properties.json b/open-pdf-studio/js/i18n/locales/da/properties.json index 2a845fafa..0c0762d6b 100644 --- a/open-pdf-studio/js/i18n/locales/da/properties.json +++ b/open-pdf-studio/js/i18n/locales/da/properties.json @@ -41,7 +41,10 @@ "cancelled": "Annulleret", "completed": "Fuldført", "reviewed": "Gennemgået", - "statusSetBy": "Angivet af {{author}} den {{date}}" + "statusSetBy": "Angivet af {{author}} den {{date}}", + "layer": "Lag", + "layerMarkupGroup": "Markeringslag", + "layerDocumentGroup": "Dokumentets lag" }, "colorNone": "Ingen", "moreColors": "Flere farver...", @@ -421,7 +424,12 @@ "addPage": "Tilføj side", "pageLabels": "Sideetiketter (navnegenerator)", "portfolio": "PDF-portefølje", - "recognizeForm": "Genkend formularfelter" + "recognizeForm": "Genkend formularfelter", + "layersSetDefault": "Angiv som standard", + "layersSetDefaultTitle": "Gem lagenes aktuelle synlighed som dokumentets standardtilstand. Den skrives til filen, næste gang du gemmer.", + "layersDefaultPending": "Bliver standard ved næste gem", + "layerLocked": "Låst lag: kan ikke slås til eller fra", + "layerRadioGroup": "Valggruppe: når dette lag slås til, slås de andre lag i gruppen fra" }, "scaleBar": { "title": "Skalabjælke", diff --git a/open-pdf-studio/js/i18n/locales/de/properties.json b/open-pdf-studio/js/i18n/locales/de/properties.json index 073191cc1..1eb26e287 100644 --- a/open-pdf-studio/js/i18n/locales/de/properties.json +++ b/open-pdf-studio/js/i18n/locales/de/properties.json @@ -41,7 +41,10 @@ "cancelled": "Abgebrochen", "completed": "Abgeschlossen", "reviewed": "Überprüft", - "statusSetBy": "Festgelegt von {{author}} am {{date}}" + "statusSetBy": "Festgelegt von {{author}} am {{date}}", + "layer": "Ebene", + "layerMarkupGroup": "Markierungsebenen", + "layerDocumentGroup": "Dokumentebenen" }, "colorNone": "Keine", "moreColors": "Weitere Farben...", @@ -421,7 +424,12 @@ "addPage": "Seite hinzufügen", "pageLabels": "Seitenbeschriftungen (Namensgenerator)", "portfolio": "PDF-Portfolio", - "recognizeForm": "Formularfelder erkennen" + "recognizeForm": "Formularfelder erkennen", + "layersSetDefault": "Als Standard festlegen", + "layersSetDefaultTitle": "Die aktuelle Sichtbarkeit der Ebenen als Standardzustand des Dokuments speichern. Sie wird beim nächsten Speichern in die Datei geschrieben.", + "layersDefaultPending": "Wird beim Speichern zum Standard", + "layerLocked": "Gesperrte Ebene: kann nicht ein- oder ausgeschaltet werden", + "layerRadioGroup": "Auswahlgruppe: Wenn diese Ebene eingeschaltet wird, werden die anderen Ebenen der Gruppe ausgeschaltet" }, "scaleBar": { "title": "Maßstabsleiste", diff --git a/open-pdf-studio/js/i18n/locales/el/properties.json b/open-pdf-studio/js/i18n/locales/el/properties.json index e9e8e6700..9d1fd871d 100644 --- a/open-pdf-studio/js/i18n/locales/el/properties.json +++ b/open-pdf-studio/js/i18n/locales/el/properties.json @@ -41,7 +41,10 @@ "cancelled": "Ακυρωμένο", "completed": "Ολοκληρωμένο", "reviewed": "Αξιολογημένο", - "statusSetBy": "Ορίστηκε από {{author}} στις {{date}}" + "statusSetBy": "Ορίστηκε από {{author}} στις {{date}}", + "layer": "Επίπεδο", + "layerMarkupGroup": "Επίπεδα σήμανσης", + "layerDocumentGroup": "Επίπεδα εγγράφου" }, "colorNone": "Κανένα", "moreColors": "Περισσότερα χρώματα...", @@ -421,7 +424,12 @@ "addPage": "Προσθήκη σελίδας", "pageLabels": "Ετικέτες σελίδων (δημιουργία ονομάτων)", "portfolio": "Χαρτοφυλάκιο PDF", - "recognizeForm": "Αναγνώριση πεδίων φόρμας" + "recognizeForm": "Αναγνώριση πεδίων φόρμας", + "layersSetDefault": "Ορισμός ως προεπιλογή", + "layersSetDefaultTitle": "Αποθήκευση της τρέχουσας ορατότητας των επιπέδων ως προεπιλεγμένης κατάστασης του εγγράφου. Γράφεται στο αρχείο την επόμενη φορά που θα αποθηκεύσετε.", + "layersDefaultPending": "Θα γίνει προεπιλογή κατά την αποθήκευση", + "layerLocked": "Κλειδωμένο επίπεδο: δεν μπορεί να ενεργοποιηθεί ή να απενεργοποιηθεί", + "layerRadioGroup": "Ομάδα επιλογής: η ενεργοποίηση αυτού του επιπέδου απενεργοποιεί τα άλλα επίπεδα της ομάδας" }, "scaleBar": { "title": "Γραμμή κλίμακας", diff --git a/open-pdf-studio/js/i18n/locales/en/properties.json b/open-pdf-studio/js/i18n/locales/en/properties.json index 10f285d9d..f31211e34 100644 --- a/open-pdf-studio/js/i18n/locales/en/properties.json +++ b/open-pdf-studio/js/i18n/locales/en/properties.json @@ -21,7 +21,10 @@ "cancelled": "Cancelled", "completed": "Completed", "reviewed": "Reviewed", - "statusSetBy": "Set by {{author}} on {{date}}" + "statusSetBy": "Set by {{author}} on {{date}}", + "layer": "Layer", + "layerMarkupGroup": "Markup layers", + "layerDocumentGroup": "Document layers" }, "colorNone": "None", "moreColors": "More Colors...", @@ -453,7 +456,12 @@ "addPage": "Add page", "pageLabels": "Page labels (name builder)", "portfolio": "PDF portfolio", - "recognizeForm": "Recognize form fields" + "recognizeForm": "Recognize form fields", + "layersSetDefault": "Set as default", + "layersSetDefaultTitle": "Store the current layer visibility as the document's default state. It is written to the file the next time you save.", + "layersDefaultPending": "Saved as default on next save", + "layerLocked": "Locked layer: cannot be switched on or off", + "layerRadioGroup": "Choice group: switching this layer on switches the others in its group off" }, "schedules": { "title": "Schedules", diff --git a/open-pdf-studio/js/i18n/locales/es/properties.json b/open-pdf-studio/js/i18n/locales/es/properties.json index bacd7adf0..1ad18a991 100644 --- a/open-pdf-studio/js/i18n/locales/es/properties.json +++ b/open-pdf-studio/js/i18n/locales/es/properties.json @@ -41,7 +41,10 @@ "cancelled": "Cancelado", "completed": "Completado", "reviewed": "Revisado", - "statusSetBy": "Establecido por {{author}} el {{date}}" + "statusSetBy": "Establecido por {{author}} el {{date}}", + "layer": "Capa", + "layerMarkupGroup": "Capas de marcas", + "layerDocumentGroup": "Capas del documento" }, "colorNone": "Ninguno", "moreColors": "Más colores...", @@ -421,7 +424,12 @@ "addPage": "Añadir página", "pageLabels": "Etiquetas de página (generador de nombres)", "portfolio": "Portafolio PDF", - "recognizeForm": "Reconocer campos de formulario" + "recognizeForm": "Reconocer campos de formulario", + "layersSetDefault": "Establecer como predeterminado", + "layersSetDefaultTitle": "Guardar la visibilidad actual de las capas como estado predeterminado del documento. Se escribe en el archivo la próxima vez que guarde.", + "layersDefaultPending": "Será el predeterminado al guardar", + "layerLocked": "Capa bloqueada: no se puede activar ni desactivar", + "layerRadioGroup": "Grupo de opciones: al activar esta capa se desactivan las demás capas del grupo" }, "scaleBar": { "title": "Barra de escala", diff --git a/open-pdf-studio/js/i18n/locales/fa/properties.json b/open-pdf-studio/js/i18n/locales/fa/properties.json index a54e63082..1c25cc35c 100644 --- a/open-pdf-studio/js/i18n/locales/fa/properties.json +++ b/open-pdf-studio/js/i18n/locales/fa/properties.json @@ -41,7 +41,10 @@ "cancelled": "لغوشده", "completed": "تکمیل‌شده", "reviewed": "بررسی‌شده", - "statusSetBy": "تنظیم‌شده توسط {{author}} در {{date}}" + "statusSetBy": "تنظیم‌شده توسط {{author}} در {{date}}", + "layer": "لایه", + "layerMarkupGroup": "لایه‌های نشانه‌گذاری", + "layerDocumentGroup": "لایه‌های سند" }, "colorNone": "هیچ", "moreColors": "رنگ‌های بیشتر...", @@ -421,7 +424,12 @@ "addPage": "افزودن صفحه", "pageLabels": "برچسب‌های صفحه (سازنده نام)", "portfolio": "پرتفولیوی PDF", - "recognizeForm": "شناسایی فیلدهای فرم" + "recognizeForm": "شناسایی فیلدهای فرم", + "layersSetDefault": "تنظیم به‌عنوان پیش‌فرض", + "layersSetDefaultTitle": "نمایان بودن فعلی لایه‌ها به‌عنوان وضعیت پیش‌فرض سند نگه داشته شود. در ذخیره بعدی در فایل نوشته می‌شود.", + "layersDefaultPending": "هنگام ذخیره پیش‌فرض می‌شود", + "layerLocked": "لایه قفل‌شده: نمی‌توان آن را روشن یا خاموش کرد", + "layerRadioGroup": "گروه انتخاب: روشن کردن این لایه، لایه‌های دیگر گروه را خاموش می‌کند" }, "scaleBar": { "title": "نوار مقیاس", diff --git a/open-pdf-studio/js/i18n/locales/fi/properties.json b/open-pdf-studio/js/i18n/locales/fi/properties.json index 05de1f22f..73e1e640a 100644 --- a/open-pdf-studio/js/i18n/locales/fi/properties.json +++ b/open-pdf-studio/js/i18n/locales/fi/properties.json @@ -41,7 +41,10 @@ "cancelled": "Peruutettu", "completed": "Valmis", "reviewed": "Tarkastettu", - "statusSetBy": "Asettanut {{author}} {{date}}" + "statusSetBy": "Asettanut {{author}} {{date}}", + "layer": "Taso", + "layerMarkupGroup": "Merkintätasot", + "layerDocumentGroup": "Asiakirjan tasot" }, "colorNone": "Ei mitään", "moreColors": "Lisää värejä...", @@ -421,7 +424,12 @@ "addPage": "Lisää sivu", "pageLabels": "Sivujen nimiöt (nimien muodostus)", "portfolio": "PDF-portfolio", - "recognizeForm": "Tunnista lomakekentät" + "recognizeForm": "Tunnista lomakekentät", + "layersSetDefault": "Aseta oletukseksi", + "layersSetDefaultTitle": "Tallenna tasojen nykyinen näkyvyys asiakirjan oletustilaksi. Se kirjoitetaan tiedostoon seuraavan tallennuksen yhteydessä.", + "layersDefaultPending": "Tulee oletukseksi tallennettaessa", + "layerLocked": "Lukittu taso: ei voi kytkeä päälle tai pois", + "layerRadioGroup": "Valintaryhmä: tämän tason kytkeminen päälle kytkee ryhmän muut tasot pois" }, "scaleBar": { "title": "Mittakaavajana", diff --git a/open-pdf-studio/js/i18n/locales/fr/properties.json b/open-pdf-studio/js/i18n/locales/fr/properties.json index 2a0867572..3d368421a 100644 --- a/open-pdf-studio/js/i18n/locales/fr/properties.json +++ b/open-pdf-studio/js/i18n/locales/fr/properties.json @@ -41,7 +41,10 @@ "cancelled": "Annulé", "completed": "Terminé", "reviewed": "Révisé", - "statusSetBy": "Défini par {{author}} le {{date}}" + "statusSetBy": "Défini par {{author}} le {{date}}", + "layer": "Calque", + "layerMarkupGroup": "Calques d'annotations", + "layerDocumentGroup": "Calques du document" }, "colorNone": "Aucune", "moreColors": "Plus de couleurs...", @@ -421,7 +424,12 @@ "addPage": "Ajouter une page", "pageLabels": "Étiquettes de page (générateur de noms)", "portfolio": "Portfolio PDF", - "recognizeForm": "Reconnaître les champs de formulaire" + "recognizeForm": "Reconnaître les champs de formulaire", + "layersSetDefault": "Définir par défaut", + "layersSetDefaultTitle": "Enregistrer la visibilité actuelle des calques comme état par défaut du document. Elle est écrite dans le fichier au prochain enregistrement.", + "layersDefaultPending": "Deviendra l'état par défaut à l'enregistrement", + "layerLocked": "Calque verrouillé : impossible de l'afficher ou de le masquer", + "layerRadioGroup": "Groupe de choix : afficher ce calque masque les autres calques du groupe" }, "scaleBar": { "title": "Barre d'échelle", diff --git a/open-pdf-studio/js/i18n/locales/he/properties.json b/open-pdf-studio/js/i18n/locales/he/properties.json index 9daf486fa..31e75d88b 100644 --- a/open-pdf-studio/js/i18n/locales/he/properties.json +++ b/open-pdf-studio/js/i18n/locales/he/properties.json @@ -41,7 +41,10 @@ "cancelled": "בוטל", "completed": "הושלם", "reviewed": "נסקר", - "statusSetBy": "הוגדר על ידי {{author}} בתאריך {{date}}" + "statusSetBy": "הוגדר על ידי {{author}} בתאריך {{date}}", + "layer": "שכבה", + "layerMarkupGroup": "שכבות סימון", + "layerDocumentGroup": "שכבות המסמך" }, "colorNone": "ללא", "moreColors": "צבעים נוספים...", @@ -421,7 +424,12 @@ "addPage": "הוספת עמוד", "pageLabels": "תוויות עמודים (בונה שמות)", "portfolio": "תיק PDF", - "recognizeForm": "זיהוי שדות טופס" + "recognizeForm": "זיהוי שדות טופס", + "layersSetDefault": "הגדר כברירת מחדל", + "layersSetDefaultTitle": "שמירת הנראות הנוכחית של השכבות כמצב ברירת המחדל של המסמך. היא נכתבת לקובץ בשמירה הבאה.", + "layersDefaultPending": "תהפוך לברירת מחדל בשמירה", + "layerLocked": "שכבה נעולה: לא ניתן להפעיל או לכבות אותה", + "layerRadioGroup": "קבוצת בחירה: הפעלת שכבה זו מכבה את שאר השכבות בקבוצה" }, "scaleBar": { "title": "סרגל קנה מידה", diff --git a/open-pdf-studio/js/i18n/locales/hi/properties.json b/open-pdf-studio/js/i18n/locales/hi/properties.json index 6d8056a2c..42929a193 100644 --- a/open-pdf-studio/js/i18n/locales/hi/properties.json +++ b/open-pdf-studio/js/i18n/locales/hi/properties.json @@ -41,7 +41,10 @@ "cancelled": "रद्द", "completed": "पूर्ण", "reviewed": "समीक्षित", - "statusSetBy": "{{author}} द्वारा {{date}} को सेट किया गया" + "statusSetBy": "{{author}} द्वारा {{date}} को सेट किया गया", + "layer": "परत", + "layerMarkupGroup": "मार्कअप परतें", + "layerDocumentGroup": "दस्तावेज़ की परतें" }, "colorNone": "कोई नहीं", "moreColors": "और रंग...", @@ -421,7 +424,12 @@ "addPage": "पृष्ठ जोड़ें", "pageLabels": "पृष्ठ लेबल (नाम निर्माता)", "portfolio": "PDF पोर्टफोलियो", - "recognizeForm": "फ़ॉर्म फ़ील्ड पहचानें" + "recognizeForm": "फ़ॉर्म फ़ील्ड पहचानें", + "layersSetDefault": "डिफ़ॉल्ट के रूप में सेट करें", + "layersSetDefaultTitle": "परतों की वर्तमान दृश्यता को दस्तावेज़ की डिफ़ॉल्ट स्थिति के रूप में रखें। यह अगली बार सहेजने पर फ़ाइल में लिखी जाती है।", + "layersDefaultPending": "सहेजने पर डिफ़ॉल्ट बनेगी", + "layerLocked": "लॉक की गई परत: इसे चालू या बंद नहीं किया जा सकता", + "layerRadioGroup": "विकल्प समूह: इस परत को चालू करने से समूह की अन्य परतें बंद हो जाती हैं" }, "scaleBar": { "title": "स्केल बार", diff --git a/open-pdf-studio/js/i18n/locales/hr/properties.json b/open-pdf-studio/js/i18n/locales/hr/properties.json index 58d30450b..12640f33a 100644 --- a/open-pdf-studio/js/i18n/locales/hr/properties.json +++ b/open-pdf-studio/js/i18n/locales/hr/properties.json @@ -41,7 +41,10 @@ "cancelled": "Otkazano", "completed": "Završeno", "reviewed": "Pregledano", - "statusSetBy": "Postavio/la {{author}} dana {{date}}" + "statusSetBy": "Postavio/la {{author}} dana {{date}}", + "layer": "Sloj", + "layerMarkupGroup": "Slojevi oznaka", + "layerDocumentGroup": "Slojevi dokumenta" }, "colorNone": "Ništa", "moreColors": "Više boja...", @@ -421,7 +424,12 @@ "addPage": "Dodaj stranicu", "pageLabels": "Oznake stranica (izrada naziva)", "portfolio": "PDF portfelj", - "recognizeForm": "Prepoznaj polja obrasca" + "recognizeForm": "Prepoznaj polja obrasca", + "layersSetDefault": "Postavi kao zadano", + "layersSetDefaultTitle": "Spremi trenutačnu vidljivost slojeva kao zadano stanje dokumenta. Zapisuje se u datoteku pri sljedećem spremanju.", + "layersDefaultPending": "Postaje zadano pri spremanju", + "layerLocked": "Zaključani sloj: ne može se uključiti ni isključiti", + "layerRadioGroup": "Grupa izbora: uključivanje ovog sloja isključuje ostale slojeve grupe" }, "scaleBar": { "title": "Grafičko mjerilo", diff --git a/open-pdf-studio/js/i18n/locales/hu/properties.json b/open-pdf-studio/js/i18n/locales/hu/properties.json index 351b9cc46..f6d1d9d29 100644 --- a/open-pdf-studio/js/i18n/locales/hu/properties.json +++ b/open-pdf-studio/js/i18n/locales/hu/properties.json @@ -41,7 +41,10 @@ "cancelled": "Lemondva", "completed": "Befejezve", "reviewed": "Felülvizsgálva", - "statusSetBy": "Beállította: {{author}}, {{date}}" + "statusSetBy": "Beállította: {{author}}, {{date}}", + "layer": "Réteg", + "layerMarkupGroup": "Jelölésrétegek", + "layerDocumentGroup": "A dokumentum rétegei" }, "colorNone": "Nincs", "moreColors": "További színek...", @@ -421,7 +424,12 @@ "addPage": "Oldal hozzáadása", "pageLabels": "Oldalcímkék (névkészítő)", "portfolio": "PDF-portfólió", - "recognizeForm": "Űrlapmezők felismerése" + "recognizeForm": "Űrlapmezők felismerése", + "layersSetDefault": "Beállítás alapértelmezettként", + "layersSetDefaultTitle": "A rétegek jelenlegi láthatóságának mentése a dokumentum alapértelmezett állapotaként. A következő mentéskor kerül a fájlba.", + "layersDefaultPending": "Mentéskor ez lesz az alapértelmezett", + "layerLocked": "Zárolt réteg: nem kapcsolható be vagy ki", + "layerRadioGroup": "Választócsoport: e réteg bekapcsolása kikapcsolja a csoport többi rétegét" }, "scaleBar": { "title": "Aránymérték", diff --git a/open-pdf-studio/js/i18n/locales/id/properties.json b/open-pdf-studio/js/i18n/locales/id/properties.json index 4928938a2..fff421de0 100644 --- a/open-pdf-studio/js/i18n/locales/id/properties.json +++ b/open-pdf-studio/js/i18n/locales/id/properties.json @@ -41,7 +41,10 @@ "cancelled": "Dibatalkan", "completed": "Selesai", "reviewed": "Ditinjau", - "statusSetBy": "Diatur oleh {{author}} pada {{date}}" + "statusSetBy": "Diatur oleh {{author}} pada {{date}}", + "layer": "Lapisan", + "layerMarkupGroup": "Lapisan markup", + "layerDocumentGroup": "Lapisan dokumen" }, "colorNone": "Tidak Ada", "moreColors": "Warna Lainnya...", @@ -421,7 +424,12 @@ "addPage": "Tambah halaman", "pageLabels": "Label halaman (pembuat nama)", "portfolio": "Portofolio PDF", - "recognizeForm": "Kenali bidang formulir" + "recognizeForm": "Kenali bidang formulir", + "layersSetDefault": "Jadikan bawaan", + "layersSetDefaultTitle": "Simpan visibilitas lapisan saat ini sebagai keadaan bawaan dokumen. Ditulis ke berkas saat Anda menyimpan berikutnya.", + "layersDefaultPending": "Menjadi bawaan saat disimpan", + "layerLocked": "Lapisan terkunci: tidak dapat dinyalakan atau dimatikan", + "layerRadioGroup": "Grup pilihan: menyalakan lapisan ini mematikan lapisan lain dalam grup" }, "scaleBar": { "title": "Batang Skala", diff --git a/open-pdf-studio/js/i18n/locales/it/properties.json b/open-pdf-studio/js/i18n/locales/it/properties.json index f7e66fc9d..d1a2affec 100644 --- a/open-pdf-studio/js/i18n/locales/it/properties.json +++ b/open-pdf-studio/js/i18n/locales/it/properties.json @@ -41,7 +41,10 @@ "cancelled": "Annullato", "completed": "Completato", "reviewed": "Revisionato", - "statusSetBy": "Impostato da {{author}} il {{date}}" + "statusSetBy": "Impostato da {{author}} il {{date}}", + "layer": "Livello", + "layerMarkupGroup": "Livelli di annotazione", + "layerDocumentGroup": "Livelli del documento" }, "colorNone": "Nessuno", "moreColors": "Altri colori...", @@ -421,7 +424,12 @@ "addPage": "Aggiungi pagina", "pageLabels": "Etichette di pagina (generatore di nomi)", "portfolio": "Portfolio PDF", - "recognizeForm": "Riconosci campi modulo" + "recognizeForm": "Riconosci campi modulo", + "layersSetDefault": "Imposta come predefinito", + "layersSetDefaultTitle": "Memorizza la visibilità attuale dei livelli come stato predefinito del documento. Viene scritta nel file al prossimo salvataggio.", + "layersDefaultPending": "Diventerà predefinito al salvataggio", + "layerLocked": "Livello bloccato: non può essere attivato o disattivato", + "layerRadioGroup": "Gruppo di scelta: attivando questo livello si disattivano gli altri livelli del gruppo" }, "scaleBar": { "title": "Barra di scala", diff --git a/open-pdf-studio/js/i18n/locales/ja/properties.json b/open-pdf-studio/js/i18n/locales/ja/properties.json index e412c1dda..8f1f410f7 100644 --- a/open-pdf-studio/js/i18n/locales/ja/properties.json +++ b/open-pdf-studio/js/i18n/locales/ja/properties.json @@ -41,7 +41,10 @@ "cancelled": "取消", "completed": "完了", "reviewed": "レビュー済み", - "statusSetBy": "{{author}} が {{date}} に設定" + "statusSetBy": "{{author}} が {{date}} に設定", + "layer": "レイヤー", + "layerMarkupGroup": "マークアップレイヤー", + "layerDocumentGroup": "文書のレイヤー" }, "colorNone": "なし", "moreColors": "その他の色...", @@ -421,7 +424,12 @@ "addPage": "ページを追加", "pageLabels": "ページラベル (名前ビルダー)", "portfolio": "PDF ポートフォリオ", - "recognizeForm": "フォームフィールドの認識" + "recognizeForm": "フォームフィールドの認識", + "layersSetDefault": "既定に設定", + "layersSetDefaultTitle": "現在のレイヤーの表示状態を文書の既定の状態として保存します。次回の保存時にファイルに書き込まれます。", + "layersDefaultPending": "保存時に既定になります", + "layerLocked": "ロックされたレイヤー: オン/オフを切り替えられません", + "layerRadioGroup": "選択グループ: このレイヤーをオンにすると、グループ内の他のレイヤーはオフになります" }, "scaleBar": { "title": "スケールバー", diff --git a/open-pdf-studio/js/i18n/locales/ko/properties.json b/open-pdf-studio/js/i18n/locales/ko/properties.json index a8fcf271e..e021d97b5 100644 --- a/open-pdf-studio/js/i18n/locales/ko/properties.json +++ b/open-pdf-studio/js/i18n/locales/ko/properties.json @@ -41,7 +41,10 @@ "cancelled": "취소됨", "completed": "완료됨", "reviewed": "검토됨", - "statusSetBy": "{{date}}에 {{author}}님이 설정" + "statusSetBy": "{{date}}에 {{author}}님이 설정", + "layer": "레이어", + "layerMarkupGroup": "마크업 레이어", + "layerDocumentGroup": "문서 레이어" }, "colorNone": "없음", "moreColors": "더 많은 색상...", @@ -421,7 +424,12 @@ "addPage": "페이지 추가", "pageLabels": "페이지 레이블(이름 작성기)", "portfolio": "PDF 포트폴리오", - "recognizeForm": "양식 필드 인식" + "recognizeForm": "양식 필드 인식", + "layersSetDefault": "기본값으로 설정", + "layersSetDefaultTitle": "현재 레이어 표시 상태를 문서의 기본 상태로 저장합니다. 다음에 저장할 때 파일에 기록됩니다.", + "layersDefaultPending": "저장 시 기본값이 됩니다", + "layerLocked": "잠긴 레이어: 켜거나 끌 수 없습니다", + "layerRadioGroup": "선택 그룹: 이 레이어를 켜면 그룹의 다른 레이어가 꺼집니다" }, "scaleBar": { "title": "축척 막대", diff --git a/open-pdf-studio/js/i18n/locales/lo/properties.json b/open-pdf-studio/js/i18n/locales/lo/properties.json index 0750abb92..d99b99a2a 100644 --- a/open-pdf-studio/js/i18n/locales/lo/properties.json +++ b/open-pdf-studio/js/i18n/locales/lo/properties.json @@ -21,7 +21,10 @@ "cancelled": "ຍົກເລີກແລ້ວ", "completed": "ສຳເລັດແລ້ວ", "reviewed": "ກວດສອບແລ້ວ", - "statusSetBy": "ກຳນົດໂດຍ {{author}} ເມື່ອ {{date}}" + "statusSetBy": "ກຳນົດໂດຍ {{author}} ເມື່ອ {{date}}", + "layer": "ຊັ້ນ", + "layerMarkupGroup": "ເລເຢີເຄື່ອງໝາຍ", + "layerDocumentGroup": "ຊັ້ນຂອງເອກະສານ" }, "colorNone": "ບໍ່ມີ", "moreColors": "ສີເພີ່ມເຕີມ...", @@ -453,7 +456,12 @@ "addPage": "ເພີ່ມໜ້າ", "pageLabels": "ປ້າຍຊື່ໜ້າ (ຕົວສ້າງຊື່)", "portfolio": "ແຟ້ມສະສົມ PDF", - "recognizeForm": "ຮັບຮູ້ຊ່ອງຟອມ" + "recognizeForm": "ຮັບຮູ້ຊ່ອງຟອມ", + "layersSetDefault": "ຕັ້ງເປັນຄ່າເລີ່ມຕົ້ນ", + "layersSetDefaultTitle": "ເກັບການສະແດງຊັ້ນປັດຈຸບັນເປັນສະຖານະເລີ່ມຕົ້ນຂອງເອກະສານ. ມັນຈະຖືກຂຽນລົງໄຟລ໌ເມື່ອບັນທຶກຄັ້ງຕໍ່ໄປ.", + "layersDefaultPending": "ຈະເປັນຄ່າເລີ່ມຕົ້ນເມື່ອບັນທຶກ", + "layerLocked": "ຊັ້ນທີ່ລັອກ: ບໍ່ສາມາດເປີດ ຫຼື ປິດໄດ້", + "layerRadioGroup": "ກຸ່ມທາງເລືອກ: ການເປີດຊັ້ນນີ້ຈະປິດຊັ້ນອື່ນໃນກຸ່ມ" }, "schedules": { "title": "ຕາຕະລາງລາຍການ", diff --git a/open-pdf-studio/js/i18n/locales/ms/properties.json b/open-pdf-studio/js/i18n/locales/ms/properties.json index 274bdfd61..7e6461b7b 100644 --- a/open-pdf-studio/js/i18n/locales/ms/properties.json +++ b/open-pdf-studio/js/i18n/locales/ms/properties.json @@ -41,7 +41,10 @@ "cancelled": "Dibatalkan", "completed": "Selesai", "reviewed": "Disemak", - "statusSetBy": "Ditetapkan oleh {{author}} pada {{date}}" + "statusSetBy": "Ditetapkan oleh {{author}} pada {{date}}", + "layer": "Lapisan", + "layerMarkupGroup": "Lapisan penanda", + "layerDocumentGroup": "Lapisan dokumen" }, "colorNone": "Tiada", "moreColors": "Lagi Warna...", @@ -421,7 +424,12 @@ "addPage": "Tambah halaman", "pageLabels": "Label halaman (pembina nama)", "portfolio": "Portfolio PDF", - "recognizeForm": "Kenal pasti medan borang" + "recognizeForm": "Kenal pasti medan borang", + "layersSetDefault": "Tetapkan sebagai lalai", + "layersSetDefaultTitle": "Simpan keterlihatan lapisan semasa sebagai keadaan lalai dokumen. Ia ditulis ke fail apabila anda menyimpan seterusnya.", + "layersDefaultPending": "Menjadi lalai apabila disimpan", + "layerLocked": "Lapisan dikunci: tidak boleh dihidupkan atau dimatikan", + "layerRadioGroup": "Kumpulan pilihan: menghidupkan lapisan ini mematikan lapisan lain dalam kumpulan" }, "scaleBar": { "title": "Bar Skala", diff --git a/open-pdf-studio/js/i18n/locales/nb/properties.json b/open-pdf-studio/js/i18n/locales/nb/properties.json index c2ca9c4e3..cb423739a 100644 --- a/open-pdf-studio/js/i18n/locales/nb/properties.json +++ b/open-pdf-studio/js/i18n/locales/nb/properties.json @@ -41,7 +41,10 @@ "cancelled": "Avbrutt", "completed": "Fullført", "reviewed": "Gjennomgått", - "statusSetBy": "Angitt av {{author}} den {{date}}" + "statusSetBy": "Angitt av {{author}} den {{date}}", + "layer": "Lag", + "layerMarkupGroup": "Markeringslag", + "layerDocumentGroup": "Dokumentets lag" }, "colorNone": "Ingen", "moreColors": "Flere farger...", @@ -421,7 +424,12 @@ "addPage": "Legg til side", "pageLabels": "Sideetiketter (navnebygger)", "portfolio": "PDF-portefølje", - "recognizeForm": "Gjenkjenn skjemafelt" + "recognizeForm": "Gjenkjenn skjemafelt", + "layersSetDefault": "Angi som standard", + "layersSetDefaultTitle": "Lagre lagenes gjeldende synlighet som dokumentets standardtilstand. Den skrives til filen neste gang du lagrer.", + "layersDefaultPending": "Blir standard ved neste lagring", + "layerLocked": "Låst lag: kan ikke slås av eller på", + "layerRadioGroup": "Valggruppe: når dette laget slås på, slås de andre lagene i gruppen av" }, "scaleBar": { "title": "Målestokklinjal", diff --git a/open-pdf-studio/js/i18n/locales/nl/properties.json b/open-pdf-studio/js/i18n/locales/nl/properties.json index b0cb6759f..8299b7078 100644 --- a/open-pdf-studio/js/i18n/locales/nl/properties.json +++ b/open-pdf-studio/js/i18n/locales/nl/properties.json @@ -21,7 +21,10 @@ "cancelled": "Geannuleerd", "completed": "Voltooid", "reviewed": "Beoordeeld", - "statusSetBy": "Gezet door {{author}} op {{date}}" + "statusSetBy": "Gezet door {{author}} op {{date}}", + "layer": "Laag", + "layerMarkupGroup": "Markeringslagen", + "layerDocumentGroup": "Lagen van het document" }, "colorNone": "Geen", "moreColors": "Meer kleuren...", @@ -401,7 +404,12 @@ "addPage": "Pagina toevoegen", "pageLabels": "Paginalabels (naambouwer)", "portfolio": "PDF-portfolio", - "recognizeForm": "Formuliervelden herkennen" + "recognizeForm": "Formuliervelden herkennen", + "layersSetDefault": "Als standaard instellen", + "layersSetDefaultTitle": "De huidige zichtbaarheid van de lagen bewaren als standaardstand van het document. Die wordt bij de volgende keer opslaan in het bestand geschreven.", + "layersDefaultPending": "Wordt bij opslaan de standaard", + "layerLocked": "Vergrendelde laag: kan niet aan of uit worden gezet", + "layerRadioGroup": "Keuzegroep: deze laag aanzetten zet de andere lagen van de groep uit" }, "scaleBar": { "title": "Schaalbalk", diff --git a/open-pdf-studio/js/i18n/locales/pl/properties.json b/open-pdf-studio/js/i18n/locales/pl/properties.json index 5260ed446..b526185be 100644 --- a/open-pdf-studio/js/i18n/locales/pl/properties.json +++ b/open-pdf-studio/js/i18n/locales/pl/properties.json @@ -41,7 +41,10 @@ "cancelled": "Anulowane", "completed": "Zakończone", "reviewed": "Zrecenzowane", - "statusSetBy": "Ustawione przez {{author}} dnia {{date}}" + "statusSetBy": "Ustawione przez {{author}} dnia {{date}}", + "layer": "Warstwa", + "layerMarkupGroup": "Warstwy adnotacji", + "layerDocumentGroup": "Warstwy dokumentu" }, "colorNone": "Brak", "moreColors": "Więcej Kolorów...", @@ -421,7 +424,12 @@ "addPage": "Dodaj stronę", "pageLabels": "Etykiety stron (kreator nazw)", "portfolio": "Portfolio PDF", - "recognizeForm": "Rozpoznaj pola formularza" + "recognizeForm": "Rozpoznaj pola formularza", + "layersSetDefault": "Ustaw jako domyślne", + "layersSetDefaultTitle": "Zapisz bieżącą widoczność warstw jako domyślny stan dokumentu. Zostanie zapisana w pliku przy następnym zapisie.", + "layersDefaultPending": "Stanie się domyślne po zapisaniu", + "layerLocked": "Warstwa zablokowana: nie można jej włączyć ani wyłączyć", + "layerRadioGroup": "Grupa wyboru: włączenie tej warstwy wyłącza pozostałe warstwy grupy" }, "scaleBar": { "title": "Podziałka liniowa", diff --git a/open-pdf-studio/js/i18n/locales/pt/properties.json b/open-pdf-studio/js/i18n/locales/pt/properties.json index 3eb37ecb9..6d710382d 100644 --- a/open-pdf-studio/js/i18n/locales/pt/properties.json +++ b/open-pdf-studio/js/i18n/locales/pt/properties.json @@ -41,7 +41,10 @@ "cancelled": "Cancelado", "completed": "Concluído", "reviewed": "Revisado", - "statusSetBy": "Definido por {{author}} em {{date}}" + "statusSetBy": "Definido por {{author}} em {{date}}", + "layer": "Camada", + "layerMarkupGroup": "Camadas de marcação", + "layerDocumentGroup": "Camadas do documento" }, "colorNone": "Nenhuma", "moreColors": "Mais Cores...", @@ -421,7 +424,12 @@ "addPage": "Adicionar página", "pageLabels": "Rótulos de página (gerador de nomes)", "portfolio": "Portfólio PDF", - "recognizeForm": "Reconhecer campos de formulário" + "recognizeForm": "Reconhecer campos de formulário", + "layersSetDefault": "Definir como padrão", + "layersSetDefaultTitle": "Guardar a visibilidade atual das camadas como estado padrão do documento. É gravada no arquivo na próxima vez que salvar.", + "layersDefaultPending": "Será o padrão ao salvar", + "layerLocked": "Camada bloqueada: não pode ser ativada nem desativada", + "layerRadioGroup": "Grupo de escolha: ativar esta camada desativa as outras camadas do grupo" }, "scaleBar": { "title": "Barra de escala", diff --git a/open-pdf-studio/js/i18n/locales/ro/properties.json b/open-pdf-studio/js/i18n/locales/ro/properties.json index 967217b28..260ab062c 100644 --- a/open-pdf-studio/js/i18n/locales/ro/properties.json +++ b/open-pdf-studio/js/i18n/locales/ro/properties.json @@ -41,7 +41,10 @@ "cancelled": "Anulat", "completed": "Finalizat", "reviewed": "Revizuit", - "statusSetBy": "Setat de {{author}} pe {{date}}" + "statusSetBy": "Setat de {{author}} pe {{date}}", + "layer": "Strat", + "layerMarkupGroup": "Straturi de marcaje", + "layerDocumentGroup": "Straturile documentului" }, "colorNone": "Niciunul", "moreColors": "Mai multe culori...", @@ -421,7 +424,12 @@ "addPage": "Adăugare pagină", "pageLabels": "Etichete de pagină (generator de nume)", "portfolio": "Portofoliu PDF", - "recognizeForm": "Recunoaștere câmpuri de formular" + "recognizeForm": "Recunoaștere câmpuri de formular", + "layersSetDefault": "Setează ca implicit", + "layersSetDefaultTitle": "Păstrează vizibilitatea actuală a straturilor ca stare implicită a documentului. Este scrisă în fișier la următoarea salvare.", + "layersDefaultPending": "Devine implicit la salvare", + "layerLocked": "Strat blocat: nu poate fi activat sau dezactivat", + "layerRadioGroup": "Grup de alegere: activarea acestui strat le dezactivează pe celelalte din grup" }, "scaleBar": { "title": "Bară de scară", diff --git a/open-pdf-studio/js/i18n/locales/ru/properties.json b/open-pdf-studio/js/i18n/locales/ru/properties.json index bdeb46e0b..85e26ee01 100644 --- a/open-pdf-studio/js/i18n/locales/ru/properties.json +++ b/open-pdf-studio/js/i18n/locales/ru/properties.json @@ -41,7 +41,10 @@ "cancelled": "Отменено", "completed": "Завершено", "reviewed": "Проверено", - "statusSetBy": "Установлено: {{author}}, {{date}}" + "statusSetBy": "Установлено: {{author}}, {{date}}", + "layer": "Слой", + "layerMarkupGroup": "Слои пометок", + "layerDocumentGroup": "Слои документа" }, "colorNone": "Нет", "moreColors": "Больше цветов...", @@ -421,7 +424,12 @@ "addPage": "Добавить страницу", "pageLabels": "Метки страниц (конструктор имён)", "portfolio": "PDF-портфолио", - "recognizeForm": "Распознать поля формы" + "recognizeForm": "Распознать поля формы", + "layersSetDefault": "Сделать по умолчанию", + "layersSetDefaultTitle": "Сохранить текущую видимость слоёв как состояние документа по умолчанию. Оно записывается в файл при следующем сохранении.", + "layersDefaultPending": "Станет состоянием по умолчанию при сохранении", + "layerLocked": "Заблокированный слой: его нельзя включить или выключить", + "layerRadioGroup": "Группа выбора: включение этого слоя выключает остальные слои группы" }, "scaleBar": { "title": "Масштабная линейка", diff --git a/open-pdf-studio/js/i18n/locales/sk/properties.json b/open-pdf-studio/js/i18n/locales/sk/properties.json index aef582596..fb6b2d19b 100644 --- a/open-pdf-studio/js/i18n/locales/sk/properties.json +++ b/open-pdf-studio/js/i18n/locales/sk/properties.json @@ -41,7 +41,10 @@ "cancelled": "Zrušené", "completed": "Dokončené", "reviewed": "Skontrolované", - "statusSetBy": "Nastavil(a) {{author}} dňa {{date}}" + "statusSetBy": "Nastavil(a) {{author}} dňa {{date}}", + "layer": "Vrstva", + "layerMarkupGroup": "Vrstvy označení", + "layerDocumentGroup": "Vrstvy dokumentu" }, "colorNone": "Žiadna", "moreColors": "Viac farieb...", @@ -421,7 +424,12 @@ "addPage": "Pridať stranu", "pageLabels": "Menovky strán (tvorba názvov)", "portfolio": "Portfólio PDF", - "recognizeForm": "Rozpoznať polia formulára" + "recognizeForm": "Rozpoznať polia formulára", + "layersSetDefault": "Nastaviť ako predvolené", + "layersSetDefaultTitle": "Uložiť aktuálnu viditeľnosť vrstiev ako predvolený stav dokumentu. Zapíše sa do súboru pri ďalšom uložení.", + "layersDefaultPending": "Pri uložení sa stane predvoleným", + "layerLocked": "Zamknutá vrstva: nedá sa zapnúť ani vypnúť", + "layerRadioGroup": "Skupina volieb: zapnutím tejto vrstvy sa ostatné vrstvy skupiny vypnú" }, "scaleBar": { "title": "Grafická mierka", diff --git a/open-pdf-studio/js/i18n/locales/sr/properties.json b/open-pdf-studio/js/i18n/locales/sr/properties.json index 8ff987a70..aad5a41ed 100644 --- a/open-pdf-studio/js/i18n/locales/sr/properties.json +++ b/open-pdf-studio/js/i18n/locales/sr/properties.json @@ -41,7 +41,10 @@ "cancelled": "Отказано", "completed": "Завршено", "reviewed": "Прегледано", - "statusSetBy": "Поставио/ла {{author}} дана {{date}}" + "statusSetBy": "Поставио/ла {{author}} дана {{date}}", + "layer": "Слој", + "layerMarkupGroup": "Слојеви ознака", + "layerDocumentGroup": "Слојеви документа" }, "colorNone": "Ништа", "moreColors": "Више боја...", @@ -421,7 +424,12 @@ "addPage": "Додај страницу", "pageLabels": "Ознаке страница (израда назива)", "portfolio": "PDF портфолио", - "recognizeForm": "Препознај поља обрасца" + "recognizeForm": "Препознај поља обрасца", + "layersSetDefault": "Постави као подразумевано", + "layersSetDefaultTitle": "Сачувај тренутну видљивост слојева као подразумевано стање документа. Уписује се у датотеку при следећем чувању.", + "layersDefaultPending": "Постаје подразумевано при чувању", + "layerLocked": "Закључан слој: не може се укључити ни искључити", + "layerRadioGroup": "Група избора: укључивање овог слоја искључује остале слојеве групе" }, "scaleBar": { "title": "Графичка размера", diff --git a/open-pdf-studio/js/i18n/locales/sv/properties.json b/open-pdf-studio/js/i18n/locales/sv/properties.json index 2eecc9445..812e02733 100644 --- a/open-pdf-studio/js/i18n/locales/sv/properties.json +++ b/open-pdf-studio/js/i18n/locales/sv/properties.json @@ -41,7 +41,10 @@ "cancelled": "Avbruten", "completed": "Slutförd", "reviewed": "Granskad", - "statusSetBy": "Angiven av {{author}} den {{date}}" + "statusSetBy": "Angiven av {{author}} den {{date}}", + "layer": "Lager", + "layerMarkupGroup": "Markeringslager", + "layerDocumentGroup": "Dokumentets lager" }, "colorNone": "Ingen", "moreColors": "Fler färger...", @@ -421,7 +424,12 @@ "addPage": "Lägg till sida", "pageLabels": "Sidetiketter (namnbyggare)", "portfolio": "PDF-portfölj", - "recognizeForm": "Identifiera formulärfält" + "recognizeForm": "Identifiera formulärfält", + "layersSetDefault": "Ange som standard", + "layersSetDefaultTitle": "Spara lagrens aktuella synlighet som dokumentets standardläge. Det skrivs till filen nästa gång du sparar.", + "layersDefaultPending": "Blir standard när du sparar", + "layerLocked": "Låst lager: kan inte slås på eller av", + "layerRadioGroup": "Valgrupp: när detta lager slås på slås de andra lagren i gruppen av" }, "scaleBar": { "title": "Skalstock", diff --git a/open-pdf-studio/js/i18n/locales/sw/properties.json b/open-pdf-studio/js/i18n/locales/sw/properties.json index ce525573d..ea2f34355 100644 --- a/open-pdf-studio/js/i18n/locales/sw/properties.json +++ b/open-pdf-studio/js/i18n/locales/sw/properties.json @@ -41,7 +41,10 @@ "cancelled": "Imeghairiwa", "completed": "Imekamilika", "reviewed": "Imepitiwa", - "statusSetBy": "Imewekwa na {{author}} tarehe {{date}}" + "statusSetBy": "Imewekwa na {{author}} tarehe {{date}}", + "layer": "Tabaka", + "layerMarkupGroup": "Tabaka za alama", + "layerDocumentGroup": "Tabaka za hati" }, "colorNone": "Hakuna", "moreColors": "Rangi Zaidi...", @@ -421,7 +424,12 @@ "addPage": "Ongeza ukurasa", "pageLabels": "Lebo za kurasa (kiunda majina)", "portfolio": "Jalada la PDF", - "recognizeForm": "Tambua sehemu za fomu" + "recognizeForm": "Tambua sehemu za fomu", + "layersSetDefault": "Weka kama chaguo-msingi", + "layersSetDefaultTitle": "Hifadhi mwonekano wa sasa wa tabaka kama hali chaguo-msingi ya hati. Huandikwa kwenye faili utakapohifadhi tena.", + "layersDefaultPending": "Itakuwa chaguo-msingi ukihifadhi", + "layerLocked": "Tabaka lililofungwa: haliwezi kuwashwa wala kuzimwa", + "layerRadioGroup": "Kikundi cha chaguo: kuwasha tabaka hili huzima tabaka zingine za kikundi" }, "scaleBar": { "title": "Upau wa Kipimo", diff --git a/open-pdf-studio/js/i18n/locales/ta/properties.json b/open-pdf-studio/js/i18n/locales/ta/properties.json index f841c4e4f..739665899 100644 --- a/open-pdf-studio/js/i18n/locales/ta/properties.json +++ b/open-pdf-studio/js/i18n/locales/ta/properties.json @@ -41,7 +41,10 @@ "cancelled": "ரத்துசெய்யப்பட்டது", "completed": "நிறைவடைந்தது", "reviewed": "மதிப்பாய்வு செய்யப்பட்டது", - "statusSetBy": "{{author}} அவர்களால் {{date}} அன்று அமைக்கப்பட்டது" + "statusSetBy": "{{author}} அவர்களால் {{date}} அன்று அமைக்கப்பட்டது", + "layer": "அடுக்கு", + "layerMarkupGroup": "குறியீட்டு அடுக்குகள்", + "layerDocumentGroup": "ஆவண அடுக்குகள்" }, "colorNone": "எதுவுமில்லை", "moreColors": "மேலும் நிறங்கள்...", @@ -421,7 +424,12 @@ "addPage": "பக்கத்தைச் சேர்", "pageLabels": "பக்க லேபிள்கள் (பெயர் உருவாக்கி)", "portfolio": "PDF போர்ட்ஃபோலியோ", - "recognizeForm": "படிவப் புலங்களை அடையாளம் காண்" + "recognizeForm": "படிவப் புலங்களை அடையாளம் காண்", + "layersSetDefault": "இயல்புநிலையாக அமை", + "layersSetDefaultTitle": "அடுக்குகளின் தற்போதைய தெரிவுநிலையை ஆவணத்தின் இயல்புநிலை நிலையாக வைக்கவும். அடுத்த முறை சேமிக்கும்போது இது கோப்பில் எழுதப்படும்.", + "layersDefaultPending": "சேமிக்கும்போது இயல்புநிலையாகும்", + "layerLocked": "பூட்டப்பட்ட அடுக்கு: இதை இயக்கவோ அணைக்கவோ முடியாது", + "layerRadioGroup": "தேர்வுக் குழு: இந்த அடுக்கை இயக்கினால் குழுவின் மற்ற அடுக்குகள் அணைக்கப்படும்" }, "scaleBar": { "title": "அளவுகோல் பட்டை", diff --git a/open-pdf-studio/js/i18n/locales/th/properties.json b/open-pdf-studio/js/i18n/locales/th/properties.json index f6a85ccf0..a785d46dc 100644 --- a/open-pdf-studio/js/i18n/locales/th/properties.json +++ b/open-pdf-studio/js/i18n/locales/th/properties.json @@ -41,7 +41,10 @@ "cancelled": "ยกเลิกแล้ว", "completed": "เสร็จสิ้นแล้ว", "reviewed": "ตรวจสอบแล้ว", - "statusSetBy": "ตั้งโดย {{author}} เมื่อ {{date}}" + "statusSetBy": "ตั้งโดย {{author}} เมื่อ {{date}}", + "layer": "ชั้น", + "layerMarkupGroup": "เลเยอร์มาร์กอัป", + "layerDocumentGroup": "ชั้นของเอกสาร" }, "colorNone": "ไม่มี", "moreColors": "สีเพิ่มเติม...", @@ -421,7 +424,12 @@ "addPage": "เพิ่มหน้า", "pageLabels": "ป้ายชื่อหน้า (ตัวสร้างชื่อ)", "portfolio": "พอร์ตโฟลิโอ PDF", - "recognizeForm": "รู้จำฟิลด์ฟอร์ม" + "recognizeForm": "รู้จำฟิลด์ฟอร์ม", + "layersSetDefault": "ตั้งเป็นค่าเริ่มต้น", + "layersSetDefaultTitle": "เก็บการแสดงผลชั้นปัจจุบันเป็นสถานะเริ่มต้นของเอกสาร จะถูกเขียนลงไฟล์เมื่อบันทึกครั้งถัดไป", + "layersDefaultPending": "จะเป็นค่าเริ่มต้นเมื่อบันทึก", + "layerLocked": "ชั้นที่ล็อค: ไม่สามารถเปิดหรือปิดได้", + "layerRadioGroup": "กลุ่มตัวเลือก: การเปิดชั้นนี้จะปิดชั้นอื่นในกลุ่ม" }, "scaleBar": { "title": "แถบมาตราส่วน", diff --git a/open-pdf-studio/js/i18n/locales/tr/properties.json b/open-pdf-studio/js/i18n/locales/tr/properties.json index 334ea7043..4bc370455 100644 --- a/open-pdf-studio/js/i18n/locales/tr/properties.json +++ b/open-pdf-studio/js/i18n/locales/tr/properties.json @@ -41,7 +41,10 @@ "cancelled": "İptal Edildi", "completed": "Tamamlandı", "reviewed": "İncelendi", - "statusSetBy": "{{author}} tarafından {{date}} tarihinde ayarlandı" + "statusSetBy": "{{author}} tarafından {{date}} tarihinde ayarlandı", + "layer": "Katman", + "layerMarkupGroup": "İşaretleme katmanları", + "layerDocumentGroup": "Belge katmanları" }, "colorNone": "Yok", "moreColors": "Daha Fazla Renk...", @@ -421,7 +424,12 @@ "addPage": "Sayfa ekle", "pageLabels": "Sayfa etiketleri (ad oluşturucu)", "portfolio": "PDF portföyü", - "recognizeForm": "Form alanlarını tanı" + "recognizeForm": "Form alanlarını tanı", + "layersSetDefault": "Varsayılan yap", + "layersSetDefaultTitle": "Katmanların geçerli görünürlüğünü belgenin varsayılan durumu olarak sakla. Bir sonraki kaydetmede dosyaya yazılır.", + "layersDefaultPending": "Kaydedince varsayılan olacak", + "layerLocked": "Kilitli katman: açılamaz veya kapatılamaz", + "layerRadioGroup": "Seçim grubu: bu katmanı açmak gruptaki diğer katmanları kapatır" }, "scaleBar": { "title": "Ölçek Çubuğu", diff --git a/open-pdf-studio/js/i18n/locales/uk/properties.json b/open-pdf-studio/js/i18n/locales/uk/properties.json index 5934b964e..76ab05baa 100644 --- a/open-pdf-studio/js/i18n/locales/uk/properties.json +++ b/open-pdf-studio/js/i18n/locales/uk/properties.json @@ -41,7 +41,10 @@ "cancelled": "Скасовано", "completed": "Завершено", "reviewed": "Переглянуто", - "statusSetBy": "Установлено: {{author}}, {{date}}" + "statusSetBy": "Установлено: {{author}}, {{date}}", + "layer": "Шар", + "layerMarkupGroup": "Шари розмітки", + "layerDocumentGroup": "Шари документа" }, "colorNone": "Немає", "moreColors": "Більше кольорів...", @@ -421,7 +424,12 @@ "addPage": "Додати сторінку", "pageLabels": "Мітки сторінок (конструктор назв)", "portfolio": "PDF-портфоліо", - "recognizeForm": "Розпізнати поля форми" + "recognizeForm": "Розпізнати поля форми", + "layersSetDefault": "Зробити типовим", + "layersSetDefaultTitle": "Зберегти поточну видимість шарів як типовий стан документа. Його буде записано у файл під час наступного збереження.", + "layersDefaultPending": "Стане типовим під час збереження", + "layerLocked": "Заблокований шар: його не можна ввімкнути чи вимкнути", + "layerRadioGroup": "Група вибору: увімкнення цього шару вимикає інші шари групи" }, "scaleBar": { "title": "Масштабна лінійка", diff --git a/open-pdf-studio/js/i18n/locales/ur/properties.json b/open-pdf-studio/js/i18n/locales/ur/properties.json index a9721801a..f5a33135b 100644 --- a/open-pdf-studio/js/i18n/locales/ur/properties.json +++ b/open-pdf-studio/js/i18n/locales/ur/properties.json @@ -41,7 +41,10 @@ "cancelled": "منسوخ", "completed": "مکمل", "reviewed": "جائزہ شدہ", - "statusSetBy": "{{author}} نے {{date}} کو مقرر کیا" + "statusSetBy": "{{author}} نے {{date}} کو مقرر کیا", + "layer": "پرت", + "layerMarkupGroup": "مارک اپ پرتیں", + "layerDocumentGroup": "دستاویز کی پرتیں" }, "colorNone": "کوئی نہیں", "moreColors": "مزید رنگ...", @@ -421,7 +424,12 @@ "addPage": "صفحہ شامل کریں", "pageLabels": "صفحہ لیبلز (نام ساز)", "portfolio": "PDF پورٹ فولیو", - "recognizeForm": "فارم فیلڈز کی شناخت کریں" + "recognizeForm": "فارم فیلڈز کی شناخت کریں", + "layersSetDefault": "بطور ڈیفالٹ مقرر کریں", + "layersSetDefaultTitle": "پرتوں کی موجودہ نمائش کو دستاویز کی ڈیفالٹ حالت کے طور پر محفوظ کریں۔ یہ اگلی بار محفوظ کرنے پر فائل میں لکھی جاتی ہے۔", + "layersDefaultPending": "محفوظ کرنے پر ڈیفالٹ بن جائے گی", + "layerLocked": "مقفل پرت: اسے آن یا آف نہیں کیا جا سکتا", + "layerRadioGroup": "انتخابی گروپ: اس پرت کو آن کرنے سے گروپ کی دوسری پرتیں آف ہو جاتی ہیں" }, "scaleBar": { "title": "اسکیل بار", diff --git a/open-pdf-studio/js/i18n/locales/vi/properties.json b/open-pdf-studio/js/i18n/locales/vi/properties.json index 8d9d5e653..0c0b87253 100644 --- a/open-pdf-studio/js/i18n/locales/vi/properties.json +++ b/open-pdf-studio/js/i18n/locales/vi/properties.json @@ -41,7 +41,10 @@ "cancelled": "Đã hủy", "completed": "Hoàn thành", "reviewed": "Đã xem xét", - "statusSetBy": "Đặt bởi {{author}} lúc {{date}}" + "statusSetBy": "Đặt bởi {{author}} lúc {{date}}", + "layer": "Lớp", + "layerMarkupGroup": "Lớp đánh dấu", + "layerDocumentGroup": "Lớp của tài liệu" }, "colorNone": "Không có", "moreColors": "Thêm Màu...", @@ -421,7 +424,12 @@ "addPage": "Thêm trang", "pageLabels": "Nhãn trang (trình tạo tên)", "portfolio": "Danh mục PDF", - "recognizeForm": "Nhận dạng trường biểu mẫu" + "recognizeForm": "Nhận dạng trường biểu mẫu", + "layersSetDefault": "Đặt làm mặc định", + "layersSetDefaultTitle": "Lưu trạng thái hiển thị hiện tại của các lớp làm trạng thái mặc định của tài liệu. Trạng thái này được ghi vào tệp ở lần lưu tiếp theo.", + "layersDefaultPending": "Sẽ thành mặc định khi lưu", + "layerLocked": "Lớp bị khóa: không thể bật hoặc tắt", + "layerRadioGroup": "Nhóm lựa chọn: bật lớp này sẽ tắt các lớp khác trong nhóm" }, "scaleBar": { "title": "Thước tỷ lệ", diff --git a/open-pdf-studio/js/i18n/locales/zh/properties.json b/open-pdf-studio/js/i18n/locales/zh/properties.json index 7a05c2850..528443d53 100644 --- a/open-pdf-studio/js/i18n/locales/zh/properties.json +++ b/open-pdf-studio/js/i18n/locales/zh/properties.json @@ -41,7 +41,10 @@ "cancelled": "已取消", "completed": "已完成", "reviewed": "已审阅", - "statusSetBy": "由 {{author}} 于 {{date}} 设置" + "statusSetBy": "由 {{author}} 于 {{date}} 设置", + "layer": "图层", + "layerMarkupGroup": "标记图层", + "layerDocumentGroup": "文档图层" }, "colorNone": "无", "moreColors": "更多颜色...", @@ -421,7 +424,12 @@ "addPage": "添加页面", "pageLabels": "页面标签(名称生成器)", "portfolio": "PDF 文件包", - "recognizeForm": "识别表单域" + "recognizeForm": "识别表单域", + "layersSetDefault": "设为默认", + "layersSetDefaultTitle": "将当前图层的可见性保存为文档的默认状态。下次保存时写入文件。", + "layersDefaultPending": "保存时将成为默认", + "layerLocked": "锁定的图层:无法打开或关闭", + "layerRadioGroup": "选择组:打开此图层会关闭组中的其他图层" }, "scaleBar": { "title": "比例尺", diff --git a/open-pdf-studio/js/pdf/exporter.js b/open-pdf-studio/js/pdf/exporter.js index 88991d351..64dd5edd8 100644 --- a/open-pdf-studio/js/pdf/exporter.js +++ b/open-pdf-studio/js/pdf/exporter.js @@ -91,10 +91,13 @@ export async function renderPageOffscreen(pageNum, exportScale, { deel = null, m const pdfCtx = pdfCanvas.getContext('2d'); // Render PDF page + // Geschakelde lagen (#377): afdrukken en exporteren tonen wat in beeld is. + const { pdfjsLaagOpties } = await import('./lagen-schakelen.js'); const renderContext = { canvasContext: pdfCtx, viewport: viewport, - annotationMode: 0 + annotationMode: 0, + ...pdfjsLaagOpties(doc), }; // Part of the page: shifted so the part starts at pixel (0,0); the canvas // clips the rest. diff --git a/open-pdf-studio/js/pdf/laag-ids.js b/open-pdf-studio/js/pdf/laag-ids.js new file mode 100644 index 000000000..b75d6cc6d --- /dev/null +++ b/open-pdf-studio/js/pdf/laag-ids.js @@ -0,0 +1,27 @@ +// Laag-id's (#377), zonder afhankelijkheden: ook bruikbaar in stores en het +// eigenschappenpaneel zonder pdf-lib te laden. Laag-id's van pdf.js zijn +// Ref.toString(): `12R`, met generatie `12R3`. + +/** Laag-id van pdf.js naar objectnummer en generatie, of null. */ +export function refVanPdfjsId(id) { + const m = /^(\d+)R(\d*)$/.exec(String(id || '')); + return m ? { nummer: Number(m[1]), generatie: m[2] ? Number(m[2]) : 0 } : null; +} + +// Een annotatie die op een laag van de tekening staat, heeft als `layer` het +// id van een "vreemde" annotatielaag (saver/annotatie-lagen.js, #621): +// `ocg--`. Hiermee gaan die twee vormen over en weer. +const OCG_LAAG = /^ocg-(\d+)-(\d+)$/; + +/** Annotatielaag-id bij een laag van de tekening (pdf.js-id). */ +export function annotatieLaagIdVoorOcg(pdfjsId) { + const r = refVanPdfjsId(pdfjsId); + return r ? `ocg-${r.nummer}-${r.generatie}` : null; +} + +/** De laag van de tekening (pdf.js-id) achter een annotatielaag-id, of null. */ +export function ocgVoorAnnotatieLaagId(laagId) { + const m = OCG_LAAG.exec(String(laagId || '')); + if (!m) return null; + return Number(m[2]) ? `${m[1]}R${m[2]}` : `${m[1]}R`; +} diff --git a/open-pdf-studio/js/pdf/laagstand.test.mjs b/open-pdf-studio/js/pdf/laagstand.test.mjs index 92fecc842..6fb0f60cc 100644 --- a/open-pdf-studio/js/pdf/laagstand.test.mjs +++ b/open-pdf-studio/js/pdf/laagstand.test.mjs @@ -159,7 +159,8 @@ test('het paneel laat de vinkjes schakelen (#684), met de standaardcursor', () = const lagen = lees('../ui/panels/layers.js'); const css = lees('../../styles/panels.css'); const vinkje = paneel.slice(paneel.indexOf('', paneel.indexOf(' wissel\(layer, e\.currentTarget\.checked\)\}/); assert.match(paneel, /m\.toggleLayer\(layer\.id, zichtbaar\)/); assert.match(lagen, /export async function toggleLayer\(id, zichtbaar\)/); diff --git a/open-pdf-studio/js/pdf/lagen-boom.js b/open-pdf-studio/js/pdf/lagen-boom.js new file mode 100644 index 000000000..f00bc1452 --- /dev/null +++ b/open-pdf-studio/js/pdf/lagen-boom.js @@ -0,0 +1,206 @@ +// De lagenboom van een PDF (optionele inhoud, #377), rechtstreeks uit het +// bestand gelezen met pdf-lib, en het bewaren van de laagstand als +// standaardconfiguratie (/OCProperties /D). +// +// Waarom niet alleen pdf.js: getOptionalContentConfig() geeft de lagen en +// hun stand, en schakelt de keuzegroepen (/RBGroups) goed, maar +// - laat in /Order een geneste array zonder label vallen. Volgens de +// PDF-standaard horen de lagen in zo'n array onder de laag die ervoor +// staat (`[Gebouw [Wanden Deuren]]`: Wanden en Deuren onder Gebouw); +// pdf.js zet ze achteraan, los; +// - kent /Locked niet (lagen die de gebruiker niet mag schakelen); +// - geeft de keuzegroepen niet terug om te tonen. +// Deze module leest die structuur. De namen en de stand komen in het paneel +// nog steeds uit pdf.js (dat ontsleutelt ook tekenreeksen in een versleuteld +// bestand); hier gaat het om de vorm van de boom. +// +// Laag-id's zijn die van pdf.js (Ref.toString): `12R`, met generatie `12R3`. +// +// Puur: geen app-state, geen DOM. Getest in lagen-boom.test.mjs. + +import { PDFArray, PDFDict, PDFName, PDFRef, PDFString, PDFHexString } from 'pdf-lib'; + +const N = PDFName.of; +const MAX_DIEPTE = 10; + +/** Het id dat pdf.js aan een OCG geeft: "12R", of "12R3". */ +export function pdfjsIdVanRef(ref) { + return ref.generationNumber ? `${ref.objectNumber}R${ref.generationNumber}` : `${ref.objectNumber}R`; +} + +export { refVanPdfjsId, annotatieLaagIdVoorOcg, ocgVoorAnnotatieLaagId } from './laag-ids.js'; + +function opgezocht(context, raw) { + return raw instanceof PDFRef ? context.lookup(raw) : raw; +} + +function tekst(waarde) { + if (waarde instanceof PDFHexString) return waarde.decodeText(); + if (waarde instanceof PDFString) return waarde.decodeText(); + return null; +} + +function ocProperties(pdfDoc) { + const oc = opgezocht(pdfDoc.context, pdfDoc.catalog.get(N('OCProperties'))); + return oc instanceof PDFDict ? oc : null; +} + +function standaardConfig(pdfDoc) { + const oc = ocProperties(pdfDoc); + const d = oc ? opgezocht(pdfDoc.context, oc.get(N('D'))) : null; + return d instanceof PDFDict ? d : null; +} + +function arrayVan(context, dict, sleutel) { + if (!(dict instanceof PDFDict)) return null; + const v = opgezocht(context, dict.get(N(sleutel))); + return v instanceof PDFArray ? v : null; +} + +/** + * Leest de lagen en hun boom. + * @param {import('pdf-lib').PDFDocument} pdfDoc + * @returns {null | { + * ocgs: Map, + * order: Array<{id: string, children: object[]} | {label: string, children: object[]}>, + * locked: Set, + * rbGroups: string[][], + * begin: Map, + * }} null als het document geen lagen heeft + */ +export function leesLagenBoom(pdfDoc) { + const context = pdfDoc.context; + const oc = ocProperties(pdfDoc); + const ocgLijst = arrayVan(context, oc, 'OCGs'); + if (!ocgLijst) return null; + + const ocgs = new Map(); + for (const r of ocgLijst.asArray()) { + if (!(r instanceof PDFRef)) continue; + const dict = context.lookup(r); + if (!(dict instanceof PDFDict)) continue; + const id = pdfjsIdVanRef(r); + if (!ocgs.has(id)) ocgs.set(id, { name: tekst(opgezocht(context, dict.get(N('Name')))) }); + } + if (!ocgs.size) return null; + + const d = standaardConfig(pdfDoc); + const refsIn = (sleutel) => (arrayVan(context, d, sleutel)?.asArray() || []) + .filter((r) => r instanceof PDFRef).map(pdfjsIdVanRef).filter((id) => ocgs.has(id)); + + // Beginstand: /BaseState (standaard ON), dan /ON, dan /OFF. + const basisUit = d instanceof PDFDict && String(d.get(N('BaseState'))) === '/OFF'; + const begin = new Map([...ocgs.keys()].map((id) => [id, !basisUit])); + for (const id of refsIn('ON')) begin.set(id, true); + for (const id of refsIn('OFF')) begin.set(id, false); + + const rbGroups = []; + for (const groep of arrayVan(context, d, 'RBGroups')?.asArray() || []) { + const lijst = opgezocht(context, groep); + if (!(lijst instanceof PDFArray)) continue; + const ids = lijst.asArray().filter((r) => r instanceof PDFRef).map(pdfjsIdVanRef).filter((id) => ocgs.has(id)); + if (ids.length) rbGroups.push(ids); + } + + // /Order: een OCG is een knoop; een array met een tekst vooraan is een + // label met de rest eronder; een array zonder label hoort onder de OCG die + // ervoor staat (of, zonder die, gewoon op dit niveau). Elke laag één keer. + const gehad = new Set(); + const leesOrder = (array, diepte) => { + const knopen = []; + if (!(array instanceof PDFArray) || diepte > MAX_DIEPTE) return knopen; + for (const raw of array.asArray()) { + if (raw instanceof PDFRef && ocgs.has(pdfjsIdVanRef(raw))) { + const id = pdfjsIdVanRef(raw); + if (gehad.has(id)) continue; + gehad.add(id); + knopen.push({ id, children: [] }); + continue; + } + const genest = opgezocht(context, raw); + if (!(genest instanceof PDFArray) || genest.size() === 0) continue; + const eerste = opgezocht(context, genest.get(0)); + const label = tekst(eerste); + if (label !== null) { + const rest = PDFArray.withContext(context); + for (let i = 1; i < genest.size(); i += 1) rest.push(genest.get(i)); + knopen.push({ label, children: leesOrder(rest, diepte + 1) }); + continue; + } + const kinderen = leesOrder(genest, diepte + 1); + const vorige = knopen[knopen.length - 1]; + if (vorige && 'id' in vorige && vorige.children.length === 0) vorige.children = kinderen; + else knopen.push(...kinderen); + } + return knopen; + }; + const orderArray = arrayVan(context, d, 'Order'); + const order = orderArray + ? leesOrder(orderArray, 0) + : [...ocgs.keys()].map((id) => (gehad.add(id), { id, children: [] })); + + return { ocgs, order, locked: new Set(refsIn('Locked')), rbGroups, begin }; +} + +function vervangInhoud(array, elementen) { + while (array.size() > 0) array.remove(0); + for (const e of elementen) array.push(e); +} + +/** + * Bewaart een laagstand als standaardconfiguratie van het document: /D /ON en + * /OFF krijgen de stand van de genoemde lagen. Lagen die niet in `stand` + * staan (zoals de eigen annotatielagen, die saver/annotatie-lagen.js zelf + * schrijft) en /BaseState blijven zoals ze waren. /AS (automatische stand + * per gebruik) blijft ook staan. + * + * @param {import('pdf-lib').PDFDocument} pdfDoc + * @param {Map} stand pdf.js-id → zichtbaar + * @returns {boolean} of er iets veranderde + */ +export function schrijfStandaardLaagstand(pdfDoc, stand) { + if (!(stand instanceof Map) || stand.size === 0) return false; + const context = pdfDoc.context; + const oc = ocProperties(pdfDoc); + const ocgLijst = arrayVan(context, oc, 'OCGs'); + if (!ocgLijst) return false; + const refs = new Map(); + for (const r of ocgLijst.asArray()) { + if (r instanceof PDFRef && stand.has(pdfjsIdVanRef(r))) refs.set(pdfjsIdVanRef(r), r); + } + if (!refs.size) return false; + + let d = standaardConfig(pdfDoc); + if (!d) { + d = context.obj({}); + oc.set(N('D'), d); + } + const basisUit = String(d.get(N('BaseState'))) === '/OFF'; + const beeld = () => ['ON', 'OFF'] + .map((k) => arrayVan(context, d, k)?.asArray().map(String).join(' ') ?? '-').join('|'); + const voor = beeld(); + const genoemd = (r) => r instanceof PDFRef && refs.has(pdfjsIdVanRef(r)); + + for (const sleutel of ['ON', 'OFF']) { + const lijst = arrayVan(context, d, sleutel); + if (!lijst) continue; + const over = lijst.asArray().filter((r) => !genoemd(r)); + if (over.length !== lijst.size()) vervangInhoud(lijst, over); + } + const voegToe = (sleutel, nieuwe) => { + if (!nieuwe.length) return; + let lijst = arrayVan(context, d, sleutel); + if (!lijst) { + lijst = context.obj([]); + d.set(N(sleutel), lijst); + } + for (const r of nieuwe) lijst.push(r); + }; + const aan = [...refs].filter(([id]) => stand.get(id) !== false).map(([, r]) => r); + const uit = [...refs].filter(([id]) => stand.get(id) === false).map(([, r]) => r); + // Bij /BaseState /OFF moet een zichtbare laag in /ON; anders volstaat /OFF. + // De andere lijst krijgt ze niet: dat zou dubbelop zijn. + if (basisUit) voegToe('ON', aan); + else voegToe('OFF', uit); + return beeld() !== voor; +} diff --git a/open-pdf-studio/js/pdf/lagen-boom.test.mjs b/open-pdf-studio/js/pdf/lagen-boom.test.mjs new file mode 100644 index 000000000..97f3baf47 --- /dev/null +++ b/open-pdf-studio/js/pdf/lagen-boom.test.mjs @@ -0,0 +1,206 @@ +// De lagenboom uit het bestand (#377): /Order met geneste lagen en labels, +// /Locked, /RBGroups en de beginstand; en het bewaren van een laagstand als +// standaardconfiguratie (/D /ON /OFF), teruggelezen door pdf.js. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PDFDocument, PDFHexString, PDFName, PDFString } from 'pdf-lib'; + +import { + leesLagenBoom, schrijfStandaardLaagstand, pdfjsIdVanRef, + annotatieLaagIdVoorOcg, ocgVoorAnnotatieLaagId, refVanPdfjsId, +} from './lagen-boom.js'; +import { lagenLijst } from '../ui/panels/lagen-lijst.js'; + +const N = PDFName.of; + +/** + * Een CAD-achtige tekening: + * Order [ Gebouw [ Wanden Deuren ] (Maatvoering) Maten1 Maten2 [ Los ] ] + * Gebouw heeft Wanden en Deuren onder zich (array zonder label na een laag); + * Maatvoering is een label; Los staat in een array zonder label en zonder + * laag ervoor. Variant A/B is een keuzegroep; Stramien is vergrendeld en + * staat niet in /Order. + */ +async function tekening({ baseState = null } = {}) { + const pdf = await PDFDocument.create(); + pdf.addPage([200, 200]); + const ctx = pdf.context; + const laag = (naam) => ctx.register(ctx.obj({ Type: 'OCG', Name: naam })); + const r = { + gebouw: laag(PDFString.of('Gebouw')), + wanden: laag(PDFHexString.fromText('Wanden – bestaand')), + deuren: laag(PDFString.of('Deuren')), + maten1: laag(PDFString.of('Maten 1:50')), + maten2: laag(PDFString.of('Maten 1:100')), + los: laag(PDFString.of('Los')), + variantA: laag(PDFString.of('Variant A')), + variantB: laag(PDFString.of('Variant B')), + stramien: laag(PDFString.of('Stramien')), + }; + const order = ctx.obj([ + r.gebouw, + ctx.obj([r.wanden, r.deuren]), + ctx.obj([PDFString.of('Maatvoering'), r.maten1, r.maten2]), + ctx.obj([r.los]), + ctx.obj([PDFString.of('Varianten'), r.variantA, r.variantB]), + ]); + const d = ctx.obj({ + Order: order, + OFF: [r.maten2, r.variantB], + Locked: [r.stramien], + RBGroups: [[r.variantA, r.variantB]], + }); + if (baseState) d.set(N('BaseState'), N(baseState)); + if (baseState === 'OFF') d.set(N('ON'), ctx.obj([r.gebouw, r.wanden, r.variantA])); + pdf.catalog.set(N('OCProperties'), ctx.obj({ OCGs: Object.values(r), D: d })); + const ids = Object.fromEntries(Object.entries(r).map(([k, ref]) => [k, pdfjsIdVanRef(ref)])); + return { pdf, bytes: await pdf.save(), ids }; +} + +async function pdfjsConfig(bytes) { + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const doc = await pdfjs.getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; + return { doc, config: await doc.getOptionalContentConfig() }; +} + +test('id-vormen: pdf.js-id, objectverwijzing en annotatielaag-id', () => { + assert.deepEqual(refVanPdfjsId('12R'), { nummer: 12, generatie: 0 }); + assert.deepEqual(refVanPdfjsId('12R3'), { nummer: 12, generatie: 3 }); + assert.equal(refVanPdfjsId('x'), null); + assert.equal(annotatieLaagIdVoorOcg('12R'), 'ocg-12-0'); + assert.equal(annotatieLaagIdVoorOcg('12R3'), 'ocg-12-3'); + assert.equal(ocgVoorAnnotatieLaagId('ocg-12-0'), '12R'); + assert.equal(ocgVoorAnnotatieLaagId('ocg-12-3'), '12R3'); + assert.equal(ocgVoorAnnotatieLaagId('default'), null); + assert.equal(ocgVoorAnnotatieLaagId('l123'), null); +}); + +test('de boom volgt /Order: lagen onder een laag, labels en een losse array', async () => { + const { pdf, ids } = await tekening(); + const boom = leesLagenBoom(await PDFDocument.load(await pdf.save())); + assert.deepEqual(boom.order, [ + { id: ids.gebouw, children: [{ id: ids.wanden, children: [] }, { id: ids.deuren, children: [] }] }, + { label: 'Maatvoering', children: [{ id: ids.maten1, children: [] }, { id: ids.maten2, children: [] }] }, + { id: ids.los, children: [] }, + { label: 'Varianten', children: [{ id: ids.variantA, children: [] }, { id: ids.variantB, children: [] }] }, + ]); + assert.equal(boom.ocgs.get(ids.wanden).name, 'Wanden – bestaand'); + assert.deepEqual([...boom.locked], [ids.stramien]); + assert.deepEqual(boom.rbGroups, [[ids.variantA, ids.variantB]]); +}); + +test('beginstand: /BaseState ON met /OFF, en /BaseState OFF met /ON', async () => { + const aan = leesLagenBoom((await tekening()).pdf); + const { ids } = await tekening(); + assert.equal(aan.begin.get(ids.gebouw), true); + assert.equal(aan.begin.get(ids.maten2), false); + assert.equal(aan.begin.get(ids.variantB), false); + + const uitDoc = await tekening({ baseState: 'OFF' }); + const uit = leesLagenBoom(uitDoc.pdf); + assert.equal(uit.begin.get(uitDoc.ids.gebouw), true); + assert.equal(uit.begin.get(uitDoc.ids.wanden), true); + assert.equal(uit.begin.get(uitDoc.ids.deuren), false, 'BaseState OFF en niet in /ON'); + assert.equal(uit.begin.get(uitDoc.ids.variantA), true); +}); + +test('de beginstand van de boom is die van pdf.js (en dus van een lezer die /D volgt)', async () => { + for (const baseState of [null, 'OFF']) { + const { pdf, bytes } = await tekening({ baseState }); + const boom = leesLagenBoom(pdf); + const { doc, config } = await pdfjsConfig(bytes); + try { + for (const [id, groep] of config) assert.equal(boom.begin.get(id), groep.visible, `${baseState}: ${id}`); + } finally { + await doc.loadingTask.destroy(); + } + } +}); + +test('het paneel met de boom: inspringing, koppen, slot en keuzegroep', async () => { + const { bytes, pdf, ids } = await tekening(); + const boom = leesLagenBoom(pdf); + const { doc, config } = await pdfjsConfig(bytes); + try { + assert.deepEqual(lagenLijst(config, { boom }), [ + { id: ids.gebouw, name: 'Gebouw', visible: true, depth: 0 }, + { id: ids.wanden, name: 'Wanden – bestaand', visible: true, depth: 1 }, + { id: ids.deuren, name: 'Deuren', visible: true, depth: 1 }, + { kop: true, name: 'Maatvoering', depth: 0 }, + { id: ids.maten1, name: 'Maten 1:50', visible: true, depth: 1 }, + { id: ids.maten2, name: 'Maten 1:100', visible: false, depth: 1 }, + { id: ids.los, name: 'Los', visible: true, depth: 0 }, + { kop: true, name: 'Varianten', depth: 0 }, + { id: ids.variantA, name: 'Variant A', visible: true, depth: 1, radio: true }, + { id: ids.variantB, name: 'Variant B', visible: false, depth: 1, radio: true }, + // Niet in /Order: achteraan, vergrendeld. + { id: ids.stramien, name: 'Stramien', visible: true, depth: 0, locked: true }, + ]); + // Zonder boom (pdf.js alleen) vallen Wanden en Deuren uit de boom. + const zonder = lagenLijst(config); + assert.equal(zonder.find((r) => r.id === ids.wanden).depth, 0); + // Een overgeslagen laag (markeringslaag van de app) laat zijn kinderen staan. + const overgeslagen = lagenLijst(config, { boom, overslaan: new Set([ids.gebouw]) }); + assert.equal(overgeslagen[0].id, ids.wanden); + assert.equal(overgeslagen[0].depth, 0); + } finally { + await doc.loadingTask.destroy(); + } +}); + +test('keuzegroep: een laag aanzetten zet de andere uit (de stand die het paneel toont)', async () => { + const { bytes, pdf, ids } = await tekening(); + const boom = leesLagenBoom(pdf); + const { doc, config } = await pdfjsConfig(bytes); + try { + config.setVisibility(ids.variantB, true); + const regels = lagenLijst(config, { boom }); + assert.equal(regels.find((r) => r.id === ids.variantB).visible, true); + assert.equal(regels.find((r) => r.id === ids.variantA).visible, false); + } finally { + await doc.loadingTask.destroy(); + } +}); + +test('een laagstand bewaren als standaard: pdf.js leest hem terug, /Order en /Locked blijven', async () => { + for (const baseState of [null, 'OFF']) { + const { bytes, ids } = await tekening({ baseState }); + const pdf = await PDFDocument.load(bytes); + const stand = new Map([ + [ids.gebouw, false], [ids.wanden, true], [ids.deuren, true], [ids.maten1, true], + [ids.maten2, true], [ids.los, false], [ids.variantA, false], [ids.variantB, true], [ids.stramien, true], + ]); + assert.equal(schrijfStandaardLaagstand(pdf, stand), true); + const opgeslagen = await pdf.save(); + const { doc, config } = await pdfjsConfig(opgeslagen); + try { + for (const [id, zichtbaar] of stand) assert.equal(config.getGroup(id).visible, zichtbaar, `${baseState}: ${id}`); + } finally { + await doc.loadingTask.destroy(); + } + const terug = leesLagenBoom(await PDFDocument.load(opgeslagen)); + assert.deepEqual([...terug.locked], [ids.stramien]); + assert.equal(terug.order[0].children.length, 2); + // Tweede keer dezelfde stand: niets te doen. + const nogmaals = await PDFDocument.load(opgeslagen); + assert.equal(schrijfStandaardLaagstand(nogmaals, stand), false); + } +}); + +test('alleen de genoemde lagen veranderen; andere vermeldingen in /OFF blijven', async () => { + const { bytes, ids } = await tekening(); + const pdf = await PDFDocument.load(bytes); + schrijfStandaardLaagstand(pdf, new Map([[ids.gebouw, false]])); + const terug = leesLagenBoom(await PDFDocument.load(await pdf.save())); + assert.equal(terug.begin.get(ids.gebouw), false); + assert.equal(terug.begin.get(ids.maten2), false, 'niet genoemd: blijft uit'); + assert.equal(terug.begin.get(ids.variantB), false); +}); + +test('geen lagen: geen boom en niets te schrijven', async () => { + const pdf = await PDFDocument.create(); + pdf.addPage(); + assert.equal(leesLagenBoom(pdf), null); + assert.equal(schrijfStandaardLaagstand(pdf, new Map([['1R', false]])), false); +}); diff --git a/open-pdf-studio/js/pdf/lagen-render.test.mjs b/open-pdf-studio/js/pdf/lagen-render.test.mjs new file mode 100644 index 000000000..ba690be88 --- /dev/null +++ b/open-pdf-studio/js/pdf/lagen-render.test.mjs @@ -0,0 +1,243 @@ +// Lagen schakelen verandert wat er getekend wordt (#377), op de renderpaden +// die in node beschikbaar zijn: +// - pdf.js (webversie, voorvertoning, miniaturen, export/afdrukken via +// pdfjsLaagOpties): page.render met optionalContentConfigPromise; +// - het bestandspad dat PDFium op de desktop tekent: de weergavekopie uit +// laagstand.js (het bestand plus een update van /D). Altijd met MuPDF, +// dat /D op dezelfde manier volgt en hier staat voor elke lezer die het +// bestand tekent zoals het is; met een gebouwde pdfium-worker +// (OPDS_PDFIUM_WORKER) ook met PDFium zelf, onderaan; +// - de laagstand als standaard bewaard (lagen-boom.js): daarna tekent elke +// lezer de nieuwe stand vanzelf; +// - een annotatie met /OC verdwijnt met haar laag (ook in andere lezers). +// Een tekening met twee lagen: A (rood vlak links) staat aan, B (blauw vlak +// rechts) staat standaard uit. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PDFDocument, PDFName, PDFString } from 'pdf-lib'; + +import { laagstandBasis, laagstandUpdate, weergaveBytes } from './laagstand.js'; +import { schrijfStandaardLaagstand, pdfjsIdVanRef } from './lagen-boom.js'; + +const N = PDFName.of; +const MAAT = 100; +// Meetpunten in paginacoördinaten (y omhoog): midden van vlak A, B en het groene annotatievlak. +const PUNT_A = [25, 50]; +const PUNT_B = [75, 50]; +const PUNT_ANN = [50, 90]; + +async function tekening({ metAnnotatie = false } = {}) { + const pdf = await PDFDocument.create(); + const pagina = pdf.addPage([MAAT, MAAT]); + const ctx = pdf.context; + const a = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('A') })); + const b = ctx.register(ctx.obj({ Type: 'OCG', Name: PDFString.of('B') })); + pagina.node.set(N('Resources'), ctx.obj({ Properties: { LA: a, LB: b } })); + const inhoud = ctx.flateStream( + '/OC /LA BDC 1 0 0 rg 10 10 30 80 re f EMC\n/OC /LB BDC 0 0 1 rg 60 10 30 80 re f EMC\n', + ); + pagina.node.set(N('Contents'), ctx.register(inhoud)); + pdf.catalog.set(N('OCProperties'), ctx.obj({ OCGs: [a, b], D: { Order: [a, b], OFF: [b] } })); + if (metAnnotatie) { + // Een vierkant met een eigen appearance, op laag A. + const ap = ctx.register(ctx.stream('0 1 0 rg 0 0 20 6 re f', { + Type: 'XObject', Subtype: 'Form', BBox: [0, 0, 20, 6], + })); + const annot = ctx.register(ctx.obj({ + Type: 'Annot', Subtype: 'Square', Rect: [40, 87, 60, 93], F: 4, AP: { N: ap }, OC: a, + })); + pagina.node.set(N('Annots'), ctx.obj([annot])); + } + return { bytes: await pdf.save(), a: pdfjsIdVanRef(a), b: pdfjsIdVanRef(b) }; +} + +function kleur(pixel) { + const [r, g, b] = pixel; + if (r > 200 && g < 80 && b < 80) return 'rood'; + if (b > 200 && r < 80 && g < 80) return 'blauw'; + if (g > 200 && r < 80 && b < 80) return 'groen'; + if (r > 230 && g > 230 && b > 230) return 'wit'; + return `anders(${r},${g},${b})`; +} + +// ---- pdf.js --------------------------------------------------------------- + +let napiCanvas = null; +try { napiCanvas = await import('@napi-rs/canvas'); } catch { napiCanvas = null; } + +async function pdfjsDoc(bytes) { + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + return pdfjs.getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; +} + +/** Tekent pagina 1 zoals renderer.js: annotationMode 0, plus de laagopties. */ +async function pdfjsKleuren(doc, laagOpties = {}) { + const page = await doc.getPage(1); + const schaal = 2; + const canvas = napiCanvas.createCanvas(MAAT * schaal, MAAT * schaal); + await page.render({ + canvas, + viewport: page.getViewport({ scale: schaal }), + background: '#ffffff', + annotationMode: 0, + ...laagOpties, + }).promise; + const ctx = canvas.getContext('2d'); + const lees = ([x, y]) => kleur(ctx.getImageData(x * schaal, (MAAT - y) * schaal, 1, 1).data); + return { a: lees(PUNT_A), b: lees(PUNT_B) }; +} + +test('pdf.js: een laag die standaard uit staat, wordt niet getekend', { skip: napiCanvas ? false : '@napi-rs/canvas ontbreekt' }, async () => { + const { bytes } = await tekening(); + const doc = await pdfjsDoc(bytes); + try { + assert.deepEqual(await pdfjsKleuren(doc), { a: 'rood', b: 'wit' }); + } finally { + await doc.loadingTask.destroy(); + } +}); + +test('pdf.js: schakelen in de config tekent de andere stand, zonder herladen', { skip: napiCanvas ? false : '@napi-rs/canvas ontbreekt' }, async () => { + const { bytes, a, b } = await tekening(); + const doc = await pdfjsDoc(bytes); + try { + // Zoals lagen-schakelen.js: één config per document, pdfjsLaagOpties geeft hem mee. + const config = await doc.getOptionalContentConfig(); + const opties = { optionalContentConfigPromise: Promise.resolve(config) }; + config.setVisibility(a, false); + config.setVisibility(b, true); + assert.deepEqual(await pdfjsKleuren(doc, opties), { a: 'wit', b: 'blauw' }); + config.setVisibility(a, true); + assert.deepEqual(await pdfjsKleuren(doc, opties), { a: 'rood', b: 'blauw' }); + // Zonder de opties: nog steeds de beginstand van het document. + assert.deepEqual(await pdfjsKleuren(doc), { a: 'rood', b: 'wit' }); + } finally { + await doc.loadingTask.destroy(); + } +}); + +// ---- het bestand zelf (PDFium-pad, hier met MuPDF) ------------------------- + +let mupdf = null; +try { mupdf = await import('mupdf'); } catch { mupdf = null; } + +function mupdfKleuren(bytes) { + const doc = mupdf.Document.openDocument(bytes, 'application/pdf'); + let page; + let pix; + try { + page = doc.loadPage(0); + const schaal = 2; + pix = page.toPixmap(mupdf.Matrix.scale(schaal, schaal), mupdf.ColorSpace.DeviceRGB, false, true); + const breedte = pix.getWidth(); + const data = pix.getPixels(); + const lees = ([x, y]) => { + const o = (Math.round((MAAT - y) * schaal) * breedte + Math.round(x * schaal)) * 3; + return kleur([data[o], data[o + 1], data[o + 2]]); + }; + return { a: lees(PUNT_A), b: lees(PUNT_B), ann: lees(PUNT_ANN) }; + } finally { + pix?.destroy(); + page?.destroy(); + doc.destroy(); + } +} + +test('bestand: een laag die standaard uit staat, tekent een lezer ook niet', { skip: mupdf ? false : 'mupdf ontbreekt' }, async () => { + const { bytes } = await tekening(); + const k = mupdfKleuren(bytes); + assert.equal(k.a, 'rood'); + assert.equal(k.b, 'wit'); +}); + +test('bestand: de weergavekopie (laagstand.js) tekent de geschakelde stand', { skip: mupdf ? false : 'mupdf ontbreekt' }, async () => { + const { bytes, a } = await tekening(); + const basis = await laagstandBasis(bytes); + // A uit, B aan (B staat dan niet meer in /OFF). + const kopie = weergaveBytes(bytes, laagstandUpdate(basis, [a])); + const k = mupdfKleuren(kopie); + assert.deepEqual([k.a, k.b], ['wit', 'blauw']); + // Alles aan. + const k2 = mupdfKleuren(weergaveBytes(bytes, laagstandUpdate(basis, []))); + assert.deepEqual([k2.a, k2.b], ['rood', 'blauw']); +}); + +test('bestand: de stand als standaard bewaard, tekent elke lezer vanzelf', { skip: mupdf && napiCanvas ? false : 'mupdf of @napi-rs/canvas ontbreekt' }, async () => { + const { bytes, a, b } = await tekening(); + const pdf = await PDFDocument.load(bytes); + assert.equal(schrijfStandaardLaagstand(pdf, new Map([[a, false], [b, true]])), true); + const opgeslagen = await pdf.save(); + const k = mupdfKleuren(opgeslagen); + assert.deepEqual([k.a, k.b], ['wit', 'blauw']); + const doc = await pdfjsDoc(opgeslagen); + try { + assert.deepEqual(await pdfjsKleuren(doc), { a: 'wit', b: 'blauw' }); + } finally { + await doc.loadingTask.destroy(); + } +}); + +test('bestand: een annotatie met /OC verdwijnt met haar laag', { skip: mupdf ? false : 'mupdf ontbreekt' }, async () => { + const { bytes, a } = await tekening({ metAnnotatie: true }); + assert.equal(mupdfKleuren(bytes).ann, 'groen'); + const basis = await laagstandBasis(bytes); + const k = mupdfKleuren(weergaveBytes(bytes, laagstandUpdate(basis, [a]))); + assert.equal(k.a, 'wit'); + assert.equal(k.ann, 'wit', 'laag A uit: de annotatie op laag A ook'); +}); + +// ---- PDFium zelf, via de echte pdfium-worker --------------------------------- +// Alleen als er een gebouwde worker is: OPDS_PDFIUM_WORKER= (libpdfium naast het programma). Zelfde opdrachten als de +// werkerpool van de desktop (render, slot, namespace, gedeeld geheugen). + +const WORKER = process.env.OPDS_PDFIUM_WORKER || ''; + +test('PDFium (pdfium-worker): beginstand, weergavekopie en bewaarde standaard', { skip: WORKER ? false : 'OPDS_PDFIUM_WORKER niet gezet' }, async () => { + const [{ spawn }, fs, os, path, readline] = await Promise.all([ + import('node:child_process'), import('node:fs'), import('node:os'), import('node:path'), import('node:readline'), + ]); + const { bytes, a, b } = await tekening(); + const basis = await laagstandBasis(bytes); + const bewaard = await PDFDocument.load(bytes); + schrijfStandaardLaagstand(bewaard, new Map([[a, false], [b, true]])); + const map = fs.mkdtempSync(path.join(os.tmpdir(), 'opds-lagen-')); + const bestanden = { + begin: bytes, + kopie: weergaveBytes(bytes, laagstandUpdate(basis, [a])), + bewaard: await bewaard.save(), + }; + for (const [naam, inhoud] of Object.entries(bestanden)) fs.writeFileSync(path.join(map, `${naam}.pdf`), inhoud); + + const ns = `lagentest${process.pid}`; + const worker = spawn(WORKER, ['97', ns], { stdio: ['pipe', 'pipe', 'inherit'] }); + const regels = readline.createInterface({ input: worker.stdout })[Symbol.asyncIterator](); + try { + assert.match((await regels.next()).value, /"op":"ready"/); + const kleuren = {}; + let id = 0; + for (const naam of Object.keys(bestanden)) { + worker.stdin.write(`${JSON.stringify({ op: 'render', id: ++id, path: path.join(map, `${naam}.pdf`), page_index: 0, scale: 2, rotation: 0 })}\n`); + const antwoord = JSON.parse((await regels.next()).value); + assert.equal(antwoord.ok, true); + const shm = fs.readFileSync(path.join(os.tmpdir(), `pdfium-worker-${ns}-97.shm`)); + const lees = ([x, y]) => { + const o = 32 + (Math.round((MAAT - y) * 2) * antwoord.w + Math.round(x * 2)) * 4; + return kleur([shm[o], shm[o + 1], shm[o + 2]]); + }; + kleuren[naam] = [lees(PUNT_A), lees(PUNT_B)]; + } + assert.deepEqual(kleuren, { + begin: ['rood', 'wit'], + kopie: ['wit', 'blauw'], + bewaard: ['wit', 'blauw'], + }); + } finally { + worker.stdin.write('{"op":"shutdown"}\n'); + worker.stdin.end(); + await new Promise((r) => { worker.once('exit', r); setTimeout(r, 5000); }); + fs.rmSync(map, { recursive: true, force: true }); + fs.rmSync(path.join(os.tmpdir(), `pdfium-worker-${ns}-97.shm`), { force: true }); + } +}); diff --git a/open-pdf-studio/js/pdf/lagen-schakelen.js b/open-pdf-studio/js/pdf/lagen-schakelen.js index 536180a67..9f5b5f2a7 100644 --- a/open-pdf-studio/js/pdf/lagen-schakelen.js +++ b/open-pdf-studio/js/pdf/lagen-schakelen.js @@ -15,11 +15,20 @@ // De stand hoort bij één PDF.js-document en één pad. Vervangt een // paginabewerking de bytes (nieuw pdfDoc, nieuw werkbestand), dan geldt weer // de beginstand van het document. +// +// Bewaren als standaard (#377): "Als standaard instellen" in het paneel zet de +// huidige stand klaar; de volgende keer opslaan schrijft hem als /D /ON /OFF +// (pdf/lagen-boom.js, schrijfStandaardLaagstand). Daarna is die stand de +// beginstand van het bestand (`stand.begin`). +// +// Afdrukken en exporteren volgen de stand ook: de PDF.js-paden via +// pdfjsLaagOpties, de vectorafdruk via bytesMetLaagstand (dezelfde +// incrementele update als de weergavekopie). import { getActiveDocument } from '../core/state.js'; -import { isBeginstand, laagstandBasis, laagstandUpdate, uitgezet, weergaveBytes } from './laagstand.js'; +import { laagstandBasis, laagstandUpdate, uitgezet, weergaveBytes } from './laagstand.js'; -/** doc.id → { pdfDoc, pad, config, kopie, basis, bytes, rij } */ +/** doc.id → { pdfDoc, pad, config, begin, kopie, basis, bytes, rij } */ const standen = new Map(); /** Paden waarvoor Rust nu uit een weergavekopie tekent. */ const omgeleid = new Set(); @@ -48,6 +57,125 @@ export function pdfjsLaagOpties(doc = getActiveDocument()) { return config ? { optionalContentConfigPromise: Promise.resolve(config) } : {}; } +/** Wijkt de stand af van de beginstand van het bestand (`begin`: uit-id's)? */ +function wijktAf(stand) { + const uit = new Set(uitgezet(stand.config)); + return uit.size !== stand.begin.size || [...uit].some((id) => !stand.begin.has(id)); +} + +/** Is er een eigen stand die afwijkt van de beginstand van het bestand? */ +export function heeftAfwijkendeStand(doc = getActiveDocument()) { + const stand = doc ? standen.get(doc.id) : null; + return geldig(doc, stand) && wijktAf(stand); +} + +/** De bytes waaruit het document nu getoond wordt (zoals saver.js ze zoekt). */ +async function bronBytes(doc) { + const { getCachedPdfBytes } = await import('./loader.js'); + let bytes = doc.filePath ? getCachedPdfBytes(doc.filePath) : undefined; + if (!bytes) bytes = getCachedPdfBytes(`__memory__${doc.id}`); + if (!bytes && doc.filePath) { + const { readBinaryFile } = await platform(); + bytes = new Uint8Array(await readBinaryFile(doc.filePath)); + } + return bytes || null; +} + +/** pdfDoc → Promise (pdf/lagen-boom.js), één keer per document. */ +const bomen = new WeakMap(); + +/** + * De lagenboom van het document uit het bestand: de structuur van /Order, + * /Locked en /RBGroups die pdf.js niet (volledig) geeft. null als het bestand + * niet te lezen is; het paneel valt dan terug op de volgorde van pdf.js. + */ +export function lagenBoom(doc = getActiveDocument()) { + const pdfDoc = doc?.pdfDoc; + if (!pdfDoc) return Promise.resolve(null); + if (!bomen.has(pdfDoc)) { + bomen.set(pdfDoc, (async () => { + try { + const bytes = await bronBytes(doc); + if (!bytes) return null; + const [{ PDFDocument }, { leesLagenBoom }] = await Promise.all([import('pdf-lib'), import('./lagen-boom.js')]); + const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false, throwOnInvalidObject: false }); + return leesLagenBoom(pdf); + } catch (e) { + console.warn('[lagen] boom niet gelezen:', e); + return null; + } + })()); + } + return bomen.get(pdfDoc); +} + +/** + * Zet de huidige stand klaar om als standaardconfiguratie mee te gaan bij de + * volgende keer opslaan. @returns {boolean} of er iets klaarstaat + */ +export function markeerStandAlsStandaard(doc = getActiveDocument()) { + if (!heeftAfwijkendeStand(doc)) return false; + doc._laagStandBewaren = true; + return true; +} + +/** + * Voor de saver: de stand die als standaard bewaard moet worden (pdf.js-id → + * zichtbaar, alle lagen), of null. + */ +export function standaardOmTeBewaren(doc) { + if (!doc?._laagStandBewaren) return null; + const stand = standen.get(doc.id); + if (!geldig(doc, stand)) return null; + return new Map([...stand.config].map(([id, groep]) => [id, groep?.visible !== false])); +} + +/** + * Na een geslaagde save: de bytes zijn nieuw. Was de stand bewaard, dan is + * hij nu de beginstand van het bestand. Een weergavekopie wordt opnieuw + * gemaakt uit de nieuwe bytes (of vervalt als de stand nu de beginstand is). + */ +export async function naOpslaan(doc, { standBewaard = false } = {}) { + if (!doc) return; + if (standBewaard) doc._laagStandBewaren = false; + const stand = standen.get(doc.id); + if (!geldig(doc, stand)) return; + if (standBewaard) { + stand.begin = new Set(uitgezet(stand.config)); + // Het paneel: "Als standaard instellen" is weer niet nodig. + if (doc === getActiveDocument()) { + import('../ui/panels/layers.js').then((m) => m.updateLayersList()).catch(() => {}); + } + } + stand.bytes = null; + stand.basis = undefined; + if (!stand.kopie && !wijktAf(stand)) return; + const klaar = stand.rij.then(() => pasStandToe(doc, stand)); + stand.rij = klaar.catch(() => {}); + try { + await klaar; + } catch (e) { + console.warn('[lagen] weergavekopie na opslaan niet bijgewerkt:', e); + } +} + +/** + * De bytes voor een afdruk of export die de PDF zelf meeneemt (de + * vectorafdruk): met de geschakelde stand als /D, anders de bytes zelf. + */ +export async function bytesMetLaagstand(doc, bytes) { + const stand = doc ? standen.get(doc.id) : null; + if (!geldig(doc, stand) || !wijktAf(stand)) return bytes; + try { + const basis = await laagstandBasis(bytes); + if (!basis) return bytes; + return weergaveBytes(bytes, laagstandUpdate(basis, uitgezet(stand.config))); + } catch (e) { + console.warn('[lagen] afdruk zonder laagstand:', e); + return bytes; + } +} + async function platform() { return import('../core/platform.js'); } @@ -98,8 +226,8 @@ async function meldKopie(pad, kopie) { } /** Schrijft de weergavekopie voor deze stand; null als de beginstand geldt. */ -async function schrijfKopie(doc, stand, begin) { - if (isBeginstand(stand.config, begin)) return null; +async function schrijfKopie(doc, stand) { + if (!wijktAf(stand)) return null; const { readBinaryFile, writeBinaryFile, invoke } = await platform(); if (!stand.bytes) { const { getCachedPdfBytes } = await import('./loader.js'); @@ -131,12 +259,15 @@ async function standVoor(doc) { standen.delete(doc.id); if (oud) await laatOudeStandLos(doc, oud); const config = await doc.pdfDoc.getOptionalContentConfig(); - const stand = { pdfDoc: doc.pdfDoc, pad: doc.filePath || null, config, kopie: null, basis: undefined, bytes: null, rij: Promise.resolve() }; + // pdf.js geeft bij elke aanroep een verse config in de beginstand. + const begin = new Set(uitgezet(await doc.pdfDoc.getOptionalContentConfig())); + const stand = { pdfDoc: doc.pdfDoc, pad: doc.filePath || null, config, begin, kopie: null, basis: undefined, bytes: null, rij: Promise.resolve() }; standen.set(doc.id, stand); return stand; } async function laatOudeStandLos(doc, oud) { + doc._laagStandBewaren = false; try { if (oud.pad && oud.kopie && omgeleid.has(oud.pad)) await meldKopie(oud.pad, null); await ruimKopieOp(doc, oud.kopie); @@ -176,9 +307,8 @@ async function pasStandToe(doc, stand) { if (!geldig(doc, stand)) return; const { isTauri } = await platform(); if (isTauri() && stand.pad) { - const begin = await doc.pdfDoc.getOptionalContentConfig(); const vorige = stand.kopie; - const kopie = await schrijfKopie(doc, stand, begin); + const kopie = await schrijfKopie(doc, stand); await meldKopie(stand.pad, kopie); stand.kopie = kopie; console.log('[lagen] uit:', uitgezet(stand.config).join(',') || '-', kopie ? `kopie ${kopie}` : 'beginstand'); diff --git a/open-pdf-studio/js/pdf/print-job.js b/open-pdf-studio/js/pdf/print-job.js index 6ecbe2764..10859f756 100644 --- a/open-pdf-studio/js/pdf/print-job.js +++ b/open-pdf-studio/js/pdf/print-job.js @@ -215,7 +215,8 @@ export async function slaPrintOpAlsPdf({ try { if (!vectorMogelijk(kleur)) throw new Error(`colour mode ${kleur} prints page images`); ({ pdf } = await bouwVectorPrintPdf({ - bronBytes: await bronBytesVan(doc), + // Met de geschakelde laagstand (#377): de afdruk toont wat in beeld is. + bronBytes: await (await import('./lagen-schakelen.js')).bytesMetLaagstand(doc, await bronBytesVan(doc)), paginas: pages.map((pageNum) => ({ index: pageNum - 1, extraRotatie: getPageRotation(pageNum) })), keuzes: { papier: bronVel, orientatie: bronOrientatie, schaling, zoom, centreren }, beelden: ({ index, plaatsing }) => markeringBeelden(index + 1, plaatsing, markeringen), diff --git a/open-pdf-studio/js/pdf/save-document.test.mjs b/open-pdf-studio/js/pdf/save-document.test.mjs index a56d8182c..2443e82f3 100644 --- a/open-pdf-studio/js/pdf/save-document.test.mjs +++ b/open-pdf-studio/js/pdf/save-document.test.mjs @@ -36,7 +36,11 @@ function harness() { PDFName: { of: x => x }, KNIPSEL_CATALOGUS: 'snippets', getAnnotationStorage: doc => { owners.push(doc); return null; }, getAnnotIdToFieldName: doc => { owners.push(doc); return new Map(); }, - schrijfAnnotatieLagen() {}, maakCjkFontKeten: () => ({}), layersForSave: doc => { owners.push(doc); return []; }, currentLayerId() {}, + schrijfAnnotatieLagen() {}, + // Laagstand als standaard (#377): niets klaargezet in deze documenten. + standaardOmTeBewaren: () => null, schrijfStandaardLaagstand() {}, laagstandZonderEigenLagen() {}, + lagenNaOpslaan: async () => {}, + maakCjkFontKeten: () => ({}), layersForSave: doc => { owners.push(doc); return []; }, currentLayerId() {}, saveTextEditsToPages: async (_pdf, _pages, doc) => owners.push(doc), saveWatermarksToPages: async (_pdf, _pages, doc) => owners.push(doc), // Geen OCR-resultaten in deze documenten: geen laag (saver/ocr-text-layer.js). diff --git a/open-pdf-studio/js/pdf/saver.js b/open-pdf-studio/js/pdf/saver.js index ef6deff31..5314c2ab1 100644 --- a/open-pdf-studio/js/pdf/saver.js +++ b/open-pdf-studio/js/pdf/saver.js @@ -53,7 +53,9 @@ import { saveBookmarksToOutline } from './saver/bookmarks.js'; import { saveStylePresetsToCatalog } from './saver/style-presets.js'; import { slaPaginaLabelsOp } from './pagina-labels.js'; import { pasBijlagenToe } from './bijlagen.js'; -import { schrijfAnnotatieLagen, ocVoorAnnotatie } from './saver/annotatie-lagen.js'; +import { schrijfAnnotatieLagen, ocVoorAnnotatie, laagstandZonderEigenLagen } from './saver/annotatie-lagen.js'; +import { schrijfStandaardLaagstand } from './lagen-boom.js'; +import { standaardOmTeBewaren, naOpslaan as lagenNaOpslaan } from './lagen-schakelen.js'; import { layersForSave, currentLayerId } from '../annotations/annotatie-lagen.js'; import { vormVan, vakPadPdf } from '../annotations/rendering/textbox-vorm.js'; import { heeftNietWinAnsi, pdfTextString, toWinAnsiText, winAnsiLiteral, asciiPdfName } from './saver/pdf-text.js'; @@ -455,6 +457,16 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom huidigeLaag: currentLayerId(doc), }); + // De laagstand als standaard (#377): na "Als standaard instellen" in het + // paneel Lagen gaat de geschakelde stand van de tekening als /D /ON /OFF + // het bestand in. De eigen annotatielagen staan er net al in; een + // overgenomen laag van de tekening ('ocg-…') niet: die volgt deze stand. + const laagStandaard = standaardOmTeBewaren(doc); + if (laagStandaard) { + laagstandZonderEigenLagen(laagStandaard, laagOcgs); + schrijfStandaardLaagstand(pdfDocLib, laagStandaard); + } + // Group annotations by page const annotationsByPage = {}; for (const ann of docAnnotations) { @@ -3307,6 +3319,10 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom // Mark document as saved markDocumentSaved(activeDoc); + // Lagen (#377): de bewaarde stand is nu de beginstand van het bestand, en + // een weergavekopie volgt de nieuwe bytes. + lagenNaOpslaan(activeDoc, { standBewaard: !!laagStandaard }) + .catch((e) => console.warn('[lagen] na opslaan:', e)); // Vastgezette knipsels staan nu in de pagina-inhoud van outputPath. Zie // alInBasis(): een volgende save op die basis tekent ze niet nogmaals. diff --git a/open-pdf-studio/js/pdf/saver/annotatie-lagen-tekening.test.mjs b/open-pdf-studio/js/pdf/saver/annotatie-lagen-tekening.test.mjs new file mode 100644 index 000000000..6ee1c5fe7 --- /dev/null +++ b/open-pdf-studio/js/pdf/saver/annotatie-lagen-tekening.test.mjs @@ -0,0 +1,219 @@ +// Een markering op een laag van de tekening (#377): de annotatie krijgt bij +// het opslaan /OC naar de OCG van de tekening zelf, en komt bij het laden op +// die laag terug. De OCG blijft een laag van de tekening: zijn plek in +// /Order, /Locked en /RBGroups blijft, hij blijft in het paneel Lagen, en +// als de annotatielaag verdwijnt, blijft de OCG in het bestand. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PDFDocument, PDFName, PDFDict, PDFString } from 'pdf-lib'; + +import { + schrijfAnnotatieLagen, leesAnnotatieLagen, ocVoorAnnotatie, laagVanOc, laagstandZonderEigenLagen, +} from './annotatie-lagen.js'; +import { getLayers } from '../../annotations/annotatie-lagen.js'; +import { leesLagenBoom, pdfjsIdVanRef, annotatieLaagIdVoorOcg, schrijfStandaardLaagstand } from '../lagen-boom.js'; + +const N = PDFName.of; + +async function tekening({ deurenUit = false } = {}) { + const pdf = await PDFDocument.create({ updateMetadata: false }); + pdf.addPage([400, 300]); + const c = pdf.context; + const laag = (naam) => c.register(c.obj({ Type: 'OCG', Name: PDFString.of(naam) })); + const gebouw = laag('Gebouw'); + const wanden = laag('Wanden'); + const deuren = laag('Deuren'); + pdf.catalog.set(N('OCProperties'), c.obj({ + OCGs: [gebouw, wanden, deuren], + D: { + Order: [gebouw, [wanden, deuren]], Locked: [deuren], RBGroups: [[wanden, deuren]], + ...(deurenUit ? { OFF: [deuren] } : {}), + }, + })); + return { bytes: await pdf.save(), ids: { gebouw: pdfjsIdVanRef(gebouw), wanden: pdfjsIdVanRef(wanden), deuren: pdfjsIdVanRef(deuren) } }; +} + +/** Opslaan zoals saver.js: lagen schrijven, dan /OC op elke annotatie. */ +async function slaOp(bytes, lagen, annotaties) { + const pdf = await PDFDocument.load(bytes, { updateMetadata: false }); + const refs = schrijfAnnotatieLagen(pdf, lagen, { standaardNaam: 'Standaard' }); + const pagina = pdf.getPage(0); + const annots = []; + for (const ann of annotaties) { + const dict = pdf.context.obj({ Type: 'Annot', Subtype: 'Square', Rect: [10, 10, 50, 50], NM: PDFString.of(ann.id) }); + const oc = ocVoorAnnotatie(refs, ann); + if (oc) dict.set(N('OC'), oc); + annots.push(pdf.context.register(dict)); + } + pagina.node.set(N('Annots'), pdf.context.obj(annots)); + return { bytes: await pdf.save(), refs }; +} + +function annotLagen(pdf) { + const annots = pdf.getPage(0).node.lookup(N('Annots')); + return annots.asArray().map((r) => { + const d = pdf.context.lookup(r, PDFDict); + return [d.lookup(N('NM')).decodeText(), laagVanOc(pdf.context, d.get(N('OC')))]; + }); +} + +test('annotatie op een laag van de tekening: /OC naar die OCG, en terug bij het laden', async () => { + const { bytes, ids } = await tekening({ deurenUit: true }); + const laagId = annotatieLaagIdVoorOcg(ids.deuren); + // In de weergave aangezet om te kijken: geen nieuwe beginstand (#377). + const lagen = getLayers({ annotationLayers: [{ id: laagId, name: 'Deuren', visible: true }] }); + const { bytes: opgeslagen, refs } = await slaOp(bytes, lagen, [{ id: 'a1', layer: laagId }, { id: 'a2' }]); + assert.equal(pdfjsIdVanRef(refs.get(laagId)), ids.deuren, 'dezelfde OCG, geen nieuwe'); + + const pdf = await PDFDocument.load(opgeslagen, { updateMetadata: false }); + assert.deepEqual(annotLagen(pdf), [['a1', laagId], ['a2', 'default']]); + const gelezen = leesAnnotatieLagen(pdf); + assert.ok(gelezen.lagen.some((l) => l.id === laagId && l.name === 'Deuren' && l.visible === false)); + // Een overgenomen OCG blijft in het paneel Lagen; de eigen standaardlaag niet. + assert.equal(gelezen.ocgIds.has(ids.deuren), false); + assert.equal(gelezen.ocgIds.size, 1); + + // De boom van de tekening is ongeschonden; de stand komt in /D. + const boom = leesLagenBoom(pdf); + assert.deepEqual(boom.order[0], { + id: ids.gebouw, children: [{ id: ids.wanden, children: [] }, { id: ids.deuren, children: [] }], + }); + assert.deepEqual([...boom.locked], [ids.deuren]); + assert.deepEqual(boom.rbGroups, [[ids.wanden, ids.deuren]]); + assert.equal(boom.begin.get(ids.deuren), false, 'de beginstand van het bestand blijft'); + assert.equal(pdf.catalog.lookup(N('OCProperties'), PDFDict).lookup(N('OCGs')).size(), 4, 'drie lagen + de standaardlaag'); + + // pdf.js leest dezelfde stand: de tekeningslaag is uit. + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const doc = await pdfjs.getDocument({ data: opgeslagen.slice(), isEvalSupported: false, verbosity: 0 }).promise; + try { + const config = await doc.getOptionalContentConfig(); + assert.equal(config.getGroup(ids.deuren).visible, false); + assert.equal(config.getGroup(ids.gebouw).visible, true); + } finally { + await doc.loadingTask.destroy(); + } +}); + +test('opnieuw opslaan: dezelfde OCG, nog steeds op zijn plek in de boom', async () => { + const { bytes, ids } = await tekening(); + const laagId = annotatieLaagIdVoorOcg(ids.wanden); + const lagen = getLayers({ annotationLayers: [{ id: laagId, name: 'Wanden' }] }); + const een = await slaOp(bytes, lagen, [{ id: 'a1', layer: laagId }]); + // Bij heropenen is het een eigen laag met hetzelfde id. + const gelezen = leesAnnotatieLagen(await PDFDocument.load(een.bytes)); + const twee = await slaOp(een.bytes, getLayers({ annotationLayers: gelezen.lagen }), [{ id: 'a1', layer: laagId }]); + assert.equal(pdfjsIdVanRef(twee.refs.get(laagId)), ids.wanden); + const pdf = await PDFDocument.load(twee.bytes); + const boom = leesLagenBoom(pdf); + assert.equal(boom.order.length, 2, 'Gebouw met kinderen, en de standaardlaag achteraan'); + assert.equal(boom.order[0].children[0].id, ids.wanden); + assert.deepEqual(annotLagen(pdf), [['a1', laagId]]); +}); + +test('de annotatielaag weg: de OCG van de tekening blijft, zonder eigen sleutels', async () => { + const { bytes, ids } = await tekening(); + const laagId = annotatieLaagIdVoorOcg(ids.wanden); + const een = await slaOp(bytes, getLayers({ annotationLayers: [{ id: laagId, name: 'Wanden' }] }), [{ id: 'a1', layer: laagId }]); + // Alleen nog de standaardlaag, zonder afwijkingen: de saver krijgt null. + const twee = await slaOp(een.bytes, null, [{ id: 'a1' }]); + const pdf = await PDFDocument.load(twee.bytes); + const boom = leesLagenBoom(pdf); + assert.deepEqual([...boom.ocgs.keys()], [ids.gebouw, ids.wanden, ids.deuren]); + assert.equal(boom.order[0].children[0].id, ids.wanden); + assert.deepEqual([...boom.locked], [ids.deuren]); + const ocg = pdf.catalog.lookup(N('OCProperties'), PDFDict).lookup(N('OCGs')).asArray() + .map((r) => pdf.context.lookup(r, PDFDict)).find((d) => d.lookup(N('Name')).decodeText() === 'Wanden'); + assert.equal(ocg.get(N('OPS_LayerId')), undefined); + assert.equal(leesAnnotatieLagen(pdf), null, 'geen annotatielagen meer'); +}); + +function ocgDict(pdf, naam) { + return pdf.catalog.lookup(N('OCProperties'), PDFDict).lookup(N('OCGs')).asArray() + .map((r) => pdf.context.lookup(r, PDFDict)).find((d) => d.lookup(N('Name'))?.decodeText() === naam); +} + +test('een overgenomen laag weg terwijl er andere annotatielagen zijn: de laag van de tekening blijft heel', async () => { + const { bytes, ids } = await tekening({ deurenUit: true }); + const laagId = annotatieLaagIdVoorOcg(ids.wanden); + const notities = { id: 'l-notities', name: 'Notities' }; + const een = await slaOp(bytes, getLayers({ annotationLayers: [{ id: laagId, name: 'Wanden' }, notities] }), + [{ id: 'a1', layer: laagId }, { id: 'a2', layer: 'l-notities' }]); + // Heropenen: de OCG van de tekening heeft nu een eigen id ('ocg-...'). + const gelezen = leesAnnotatieLagen(await PDFDocument.load(een.bytes)); + assert.ok(gelezen.lagen.some((l) => l.id === laagId)); + // De annotatielaag van de tekening gaat weg; Notities blijft. + const over = gelezen.lagen.filter((l) => l.id !== laagId); + const twee = await slaOp(een.bytes, getLayers({ annotationLayers: over }), [{ id: 'a2', layer: 'l-notities' }]); + + const pdf = await PDFDocument.load(twee.bytes); + const boom = leesLagenBoom(pdf); + assert.ok(boom.ocgs.has(ids.wanden), 'Wanden staat nog in /OCGs'); + assert.equal(boom.ocgs.get(ids.wanden).name, 'Wanden'); + assert.deepEqual(boom.order[0], { + id: ids.gebouw, children: [{ id: ids.wanden, children: [] }, { id: ids.deuren, children: [] }], + }); + assert.deepEqual(boom.rbGroups, [[ids.wanden, ids.deuren]]); + assert.deepEqual([...boom.locked], [ids.deuren]); + assert.equal(boom.begin.get(ids.wanden), true); + assert.equal(boom.begin.get(ids.deuren), false); + assert.equal(ocgDict(pdf, 'Wanden').get(N('OPS_LayerId')), undefined, 'zonder eigen sleutels'); + const terug = leesAnnotatieLagen(pdf); + assert.deepEqual(terug.lagen.map((l) => l.id).sort(), ['default', 'l-notities']); +}); + +test('de naam van een overgenomen laag blijft die van de tekening', async () => { + const { bytes, ids } = await tekening(); + const laagId = annotatieLaagIdVoorOcg(ids.wanden); + // Zoals moveAnnotationsToDrawingLayer bij een bestaande markeringslaag "Wanden". + const lagen = getLayers({ annotationLayers: [{ id: 'l-wanden', name: 'Wanden' }, { id: laagId, name: 'Wanden (2)' }] }); + const een = await slaOp(bytes, lagen, [{ id: 'a1', layer: laagId }]); + let pdf = await PDFDocument.load(een.bytes); + assert.equal(leesLagenBoom(pdf).ocgs.get(ids.wanden).name, 'Wanden'); + + // Ook na hernoemen in het paneel en een tweede save. + const gelezen = leesAnnotatieLagen(pdf).lagen.map((l) => (l.id === laagId ? { ...l, name: 'Muren' } : l)); + const twee = await slaOp(een.bytes, getLayers({ annotationLayers: gelezen }), [{ id: 'a1', layer: laagId }]); + pdf = await PDFDocument.load(twee.bytes); + assert.equal(leesLagenBoom(pdf).ocgs.get(ids.wanden).name, 'Wanden'); + const wanden = pdf.catalog.lookup(N('OCProperties'), PDFDict).lookup(N('OCGs')).asArray() + .map((r) => pdf.context.lookup(r, PDFDict)).find((d) => pdfjsIdVanRef(pdf.context.getObjectRef(d)) === ids.wanden); + assert.equal(wanden.lookup(N('OPS_LayerId')).decodeText(), laagId); +}); + +test('de weergave van een overgenomen laag is geen beginstand; "Als standaard instellen" wel', async () => { + const { bytes, ids } = await tekening({ deurenUit: true }); + const wandenId = annotatieLaagIdVoorOcg(ids.wanden); + const deurenId = annotatieLaagIdVoorOcg(ids.deuren); + // Wanden in de weergave uit, Deuren aan: alleen geschakeld. + const lagen = getLayers({ annotationLayers: [ + { id: wandenId, name: 'Wanden', visible: false }, + { id: deurenId, name: 'Deuren', visible: true }, + { id: 'l-eigen', name: 'Eigen', visible: false }, + ] }); + const anns = [{ id: 'a1', layer: wandenId }, { id: 'a2', layer: deurenId }, { id: 'a3', layer: 'l-eigen' }]; + const een = await slaOp(bytes, lagen, anns); + let boom = leesLagenBoom(await PDFDocument.load(een.bytes)); + assert.equal(boom.begin.get(ids.wanden), true); + assert.equal(boom.begin.get(ids.deuren), false); + // Een eigen annotatielaag bewaart zijn zichtbaarheid zoals altijd (#468). + const eigen = leesAnnotatieLagen(await PDFDocument.load(een.bytes)).lagen.find((l) => l.id === 'l-eigen'); + assert.equal(eigen.visible, false); + + // "Als standaard instellen", zoals saver.js: de stand uit het paneel Lagen + // (pdf.js-ids) zonder de eigen annotatielagen. + const pdf = await PDFDocument.load(bytes, { updateMetadata: false }); + const refs = schrijfAnnotatieLagen(pdf, lagen, { standaardNaam: 'Standaard' }); + const eigenPdfjs = pdfjsIdVanRef(refs.get('l-eigen')); + const stand = new Map([[ids.gebouw, true], [ids.wanden, false], [ids.deuren, true], [eigenPdfjs, true]]); + laagstandZonderEigenLagen(stand, refs); + assert.deepEqual([...stand.keys()].sort(), [ids.gebouw, ids.wanden, ids.deuren].sort(), + 'de overgenomen lagen blijven erin, de eigen laag niet'); + schrijfStandaardLaagstand(pdf, stand); + const opgeslagen = await pdf.save(); + boom = leesLagenBoom(await PDFDocument.load(opgeslagen)); + assert.equal(boom.begin.get(ids.wanden), false); + assert.equal(boom.begin.get(ids.deuren), true); + assert.equal(boom.begin.get(eigenPdfjs), false, 'de eigen laag houdt zijn eigen stand'); +}); diff --git a/open-pdf-studio/js/pdf/saver/annotatie-lagen.js b/open-pdf-studio/js/pdf/saver/annotatie-lagen.js index 78971f8f4..40c71074d 100644 --- a/open-pdf-studio/js/pdf/saver/annotatie-lagen.js +++ b/open-pdf-studio/js/pdf/saver/annotatie-lagen.js @@ -37,6 +37,18 @@ // staan (geen tweede groep met dezelfde naam) en is daarna een eigen laag. // Een OCG waar geen annotatie naar wijst (inhoud van de tekening, een // watermerk) is geen annotatielaag en blijft buiten het paneel. +// +// Een laag van de tekening als annotatielaag (#377): in het eigenschappen- +// paneel kan een markering op een laag van de tekening gezet worden. Dat is +// dezelfde route: een laag 'ocg--' die bij het opslaan +// de OCG van de tekening zelf als /OC krijgt. Zo'n "overgenomen" OCG blijft +// een laag van de tekening: hij houdt zijn plek in /Order, /Locked en +// /RBGroups, blijft in het paneel Lagen staan, en verdwijnt niet uit het +// bestand als de annotatielaag weggaat (alleen de OPS_-sleutels gaan eraf). +// Ook zijn /Name en zijn beginstand in /D blijven van de tekening: de naam in +// het paneel en de geschakelde zichtbaarheid zijn weergave. Alleen "Als +// standaard instellen" (saver.js, laagstandZonderEigenLagen) zet een nieuwe +// beginstand. import { PDFName, PDFArray, PDFDict, PDFRef, PDFBool } from 'pdf-lib'; import { pdfTextString, decodePdfTextObject } from './pdf-text.js'; @@ -72,6 +84,11 @@ function isOnwaar(context, raw) { const VREEMD_ID = /^ocg-(\d+)-(\d+)$/; +/** Is dit het id van een overgenomen OCG (van de tekening of een ander programma)? */ +function isOvergenomenId(id) { + return typeof id === 'string' && VREEMD_ID.test(id); +} + /** Het laag-id voor een OCG van een ander programma (zie de kop). */ function vreemdId(ref) { return `ocg-${ref.objectNumber}-${ref.generationNumber || 0}`; @@ -173,6 +190,40 @@ function verwijderEigen(pdfDoc, eigen) { if (!ocgs || ocgs.size() === 0) pdfDoc.catalog.delete(N('OCProperties')); } +const EIGEN_SLEUTELS = ['OPS_LayerId', 'OPS_Color', 'OPS_Locked', 'OPS_Printable']; + +/** + * Eigen OCG's die uit het model verdwenen: een OCG die we zelf maakten gaat + * weg; een overgenomen OCG (zie de kop) blijft als laag van de tekening + * staan, zonder onze sleutels. + */ +function ruimEigenOp(pdfDoc, weg) { + const context = pdfDoc.context; + const verwijderen = new Set(); + for (const [id, ref] of weg) { + if (!isOvergenomenId(id)) { + verwijderen.add(ref); + continue; + } + const dict = context.lookup(ref); + if (!(dict instanceof PDFDict)) continue; + for (const k of EIGEN_SLEUTELS) dict.delete(N(k)); + if (isEigenUsage(context, dict.get(N('Usage')))) dict.delete(N('Usage')); + } + if (verwijderen.size) verwijderEigen(pdfDoc, verwijderen); +} + +/** Staat `ref` ergens in deze (mogelijk geneste) array? */ +function bevat(context, array, ref, diepte = 0) { + if (!array || diepte > 10) return false; + for (const e of array.asArray()) { + if (e === ref) return true; + const genest = opgezocht(context, e); + if (genest instanceof PDFArray && bevat(context, genest, ref, diepte + 1)) return true; + } + return false; +} + /** * Schrijf de annotatielagen in het document. * @@ -191,7 +242,7 @@ export function schrijfAnnotatieLagen(pdfDoc, lagen, { standaardNaam = 'Default' if (!Array.isArray(lagen) || lagen.length === 0) { if (bestaand.size === 0 && catalog.get(N(LAGEN_CATALOGUS_SLEUTEL)) === undefined) return null; - verwijderEigen(pdfDoc, new Set(bestaand.values())); + ruimEigenOp(pdfDoc, bestaand); catalog.delete(N(LAGEN_CATALOGUS_SLEUTEL)); return null; } @@ -208,7 +259,11 @@ export function schrijfAnnotatieLagen(pdfDoc, lagen, { standaardNaam = 'Default' } const naam = laag.id === DEFAULT_LAYER_ID ? standaardNaam : laag.name; dict.set(N('Type'), N('OCG')); - dict.set(N('Name'), pdfTextString(naam || laag.id)); + // Een overgenomen OCG houdt de naam die hij in het bestand heeft: de naam + // in het paneel kan afwijken (een achtervoegsel " (2)" tegen dubbele + // namen, of hernoemd), maar de laag van de tekening heet zoals hij heet. + const naamUitBestand = isOvergenomenId(laag.id) && tekst(context, dict.get(N('Name'))); + if (!naamUitBestand) dict.set(N('Name'), pdfTextString(naam || laag.id)); dict.set(N('OPS_LayerId'), pdfTextString(laag.id)); zetOfWis(dict, 'OPS_Color', laag.color ? pdfTextString(laag.color) : null); zetOfWis(dict, 'OPS_Locked', laag.locked ? PDFBool.True : null); @@ -231,35 +286,55 @@ export function schrijfAnnotatieLagen(pdfDoc, lagen, { standaardNaam = 'Default' ocgs = context.obj([]); oc.set(N('OCGs'), ocgs); } - const vreemdeOcgs = ocgs.asArray().filter((r) => !(r instanceof PDFRef && alleEigen.has(r))); - vervangInhoud(ocgs, [...vreemdeOcgs, ...eigenRefs]); + // Overgenomen OCG's (zie de kop) houden hun plek in /OCGs, in de boom, hun + // slot en hun keuzegroep. Dat geldt ook voor een overgenomen OCG die uit + // het model verdween (de annotatielaag is weg): na een eerdere save staat + // hij met zijn 'ocg-'-id in `bestaand`, en de inhoud van de tekening eronder + // moet schakelbaar blijven. + const overgenomen = new Set([ + ...lagen.filter((l) => l && isOvergenomenId(l.id)).map((l) => refs.get(l.id)), + ...[...bestaand].filter(([id]) => isOvergenomenId(id)).map(([, r]) => r), + ].filter(Boolean)); + // Wat al in /OCGs stond, heeft al een beginstand in /D: die is van het + // bestand. Alleen "Als standaard instellen" (saver.js) verandert hem. + const alInBestand = new Set(ocgs.asArray().filter((r) => r instanceof PDFRef)); + const standVanBestand = new Set([...overgenomen].filter((r) => alInBestand.has(r))); + const vreemdeOcgs = ocgs.asArray().filter((r) => !(r instanceof PDFRef && alleEigen.has(r) && !overgenomen.has(r))); + vervangInhoud(ocgs, [...vreemdeOcgs, ...eigenRefs.filter((r) => !vreemdeOcgs.includes(r))]); let d = opgezocht(context, oc.get(N('D'))); if (!(d instanceof PDFDict)) { d = context.obj({}); oc.set(N('D'), d); } - for (const sleutel of ['Locked', 'RBGroups']) zonderEigen(context, arrayVan(context, d, sleutel), alleEigen); + const losTeMaken = new Set([...alleEigen].filter((r) => !overgenomen.has(r))); + for (const sleutel of ['Locked', 'RBGroups']) zonderEigen(context, arrayVan(context, d, sleutel), losTeMaken); // Volgorde in het lagenpaneel van andere lezers: de tekening eerst, dan de // annotatielagen in de volgorde van ons paneel. let order = arrayVan(context, d, 'Order'); if (order) { - zonderEigen(context, order, alleEigen); - for (const r of eigenRefs) order.push(r); + const alInBoom = new Set([...overgenomen].filter((r) => bevat(context, order, r))); + zonderEigen(context, order, new Set([...alleEigen].filter((r) => !alInBoom.has(r)))); + for (const r of eigenRefs) if (!alInBoom.has(r)) order.push(r); } else { - order = context.obj([...vreemdeOcgs, ...eigenRefs]); + order = context.obj([...vreemdeOcgs, ...eigenRefs.filter((r) => !vreemdeOcgs.includes(r))]); d.set(N('Order'), order); } // Zichtbaarheid: /OFF bij een basisstand AAN (standaard), /ON bij /OFF. + // Een overgenomen OCG van de tekening houdt de beginstand van het bestand: + // zijn zichtbaarheid in het model is de weergave (#377), geen standaard. const basisUit = String(d.get(N('BaseState'))) === '/OFF'; - const verborgen = lagen.filter((l) => l && l.visible === false).map((l) => refs.get(l.id)).filter(Boolean); - const zichtbaar = eigenRefs.filter((r) => !verborgen.includes(r)); + const metStand = eigenRefs.filter((r) => !standVanBestand.has(r)); + const verborgen = lagen.filter((l) => l && l.visible === false).map((l) => refs.get(l.id)) + .filter((r) => r && !standVanBestand.has(r)); + const zichtbaar = metStand.filter((r) => !verborgen.includes(r)); + const standOpnieuw = new Set([...alleEigen].filter((r) => !standVanBestand.has(r))); let uit = arrayVan(context, d, 'OFF'); - zonderEigen(context, uit, alleEigen); + zonderEigen(context, uit, standOpnieuw); let aan = arrayVan(context, d, 'ON'); - zonderEigen(context, aan, alleEigen); + zonderEigen(context, aan, standOpnieuw); if (basisUit) { if (!aan && zichtbaar.length) { aan = context.obj([]); d.set(N('ON'), aan); } for (const r of zichtbaar) aan.push(r); @@ -290,12 +365,28 @@ export function schrijfAnnotatieLagen(pdfDoc, lagen, { standaardNaam = 'Default' catalog.set(N(LAGEN_CATALOGUS_SLEUTEL), eigen); // Lagen die uit het model verdwenen zijn: nergens meer genoemd. - const weg = new Set([...bestaand].filter(([id]) => !refs.has(id)).map(([, r]) => r)); - if (weg.size) verwijderEigen(pdfDoc, weg); + const weg = new Map([...bestaand].filter(([id, r]) => !refs.has(id) && !eigenRefs.includes(r))); + if (weg.size) ruimEigenOp(pdfDoc, weg); return refs; } +/** + * "Als standaard instellen" (#377): de stand uit het paneel Lagen (pdf.js-id + * → zichtbaar) zonder de eigen annotatielagen, want die schreef + * schrijfAnnotatieLagen al. Een overgenomen laag van de tekening ('ocg-…') + * blijft erin: zijn beginstand komt alleen via deze weg in het bestand. + * @param {Map|null} stand wordt in place aangepast + * @param {Map|null} refs wat schrijfAnnotatieLagen teruggaf + */ +export function laagstandZonderEigenLagen(stand, refs) { + if (!(stand instanceof Map) || !(refs instanceof Map)) return stand; + for (const [id, ref] of refs) { + if (!isOvergenomenId(id) && ref instanceof PDFRef) stand.delete(pdfjsId(ref)); + } + return stand; +} + /** De OCG voor /OC op een annotatie; een onbekende laag krijgt die van de standaardlaag. */ export function ocVoorAnnotatie(refs, ann) { if (!refs) return null; @@ -405,7 +496,8 @@ export function leesAnnotatieLagen(pdfDoc) { }); perId.set(id, ref); } - const eigenOcgIds = new Set([...perId.values()].map(pdfjsId)); + // Een overgenomen OCG (#377) blijft een laag van de tekening. + const eigenOcgIds = new Set([...perId].filter(([id]) => !isOvergenomenId(id)).map(([, r]) => pdfjsId(r))); for (const [id, ref] of vreemd) { if (perId.has(id)) continue; const dict = context.lookup(ref); diff --git a/open-pdf-studio/js/solid/components/left-panel/panels/LayersPanel.jsx b/open-pdf-studio/js/solid/components/left-panel/panels/LayersPanel.jsx index 52b579485..7c8ccd739 100644 --- a/open-pdf-studio/js/solid/components/left-panel/panels/LayersPanel.jsx +++ b/open-pdf-studio/js/solid/components/left-panel/panels/LayersPanel.jsx @@ -1,6 +1,8 @@ import { For, Show } from 'solid-js'; import { activeTab } from '../../../stores/leftPanelStore.js'; -import { items, countText, emptyMessage } from '../../../stores/panels/layersStore.js'; +import { + items, countText, emptyMessage, canSetDefault, defaultPending, +} from '../../../stores/panels/layersStore.js'; import { useTranslation } from '../../../../i18n/useTranslation.js'; // Inspringing per niveau van de lagenboom, in px. @@ -13,11 +15,37 @@ export default function LayersPanel() { import('../../../../ui/panels/layers.js').then((m) => m.toggleLayer(layer.id, zichtbaar)); }; + const alsStandaard = () => { + import('../../../../ui/panels/layers.js').then((m) => m.setLayerStateAsDefault()); + }; + + const titel = (layer) => { + if (layer.locked) return t('leftPanel.layerLocked'); + if (layer.radio) return t('leftPanel.layerRadioGroup'); + return layer.name; + }; + return (
{t('leftPanel.layers')}
+ + {/* De huidige stand als standaard van het document bewaren (#377). */} +
+ + + {t('leftPanel.layersDefaultPending')} + +
+
{emptyMessage()}
@@ -26,8 +54,9 @@ export default function LayersPanel() { {(layer) => (
{/* Laag aan/uit in de weergave (#684, zie pdf/lagen-schakelen.js). */} @@ -36,6 +65,7 @@ export default function LayersPanel() { type="checkbox" id={`layer-toggle-${layer.id}`} checked={layer.visible} + disabled={!!layer.locked} onChange={(e) => wissel(layer, e.currentTarget.checked)} /> @@ -45,6 +75,11 @@ export default function LayersPanel() { }> {layer.name} + + +
)}
diff --git a/open-pdf-studio/js/solid/components/properties-panel/GeneralSection.jsx b/open-pdf-studio/js/solid/components/properties-panel/GeneralSection.jsx index b9c60085e..65914daa9 100644 --- a/open-pdf-studio/js/solid/components/properties-panel/GeneralSection.jsx +++ b/open-pdf-studio/js/solid/components/properties-panel/GeneralSection.jsx @@ -2,6 +2,7 @@ import { Show, For } from 'solid-js'; import { annotProps, sectionVis, updateAnnotProp, cycleSelectNext } from '../../stores/propertiesStore.js'; import { ifcCategoryLabel, IFC_LABELS } from '../../data/ifcCategoryMap.js'; import CollapsibleSection from './CollapsibleSection.jsx'; +import LayerField from './LayerField.jsx'; import { useTranslation } from '../../../i18n/useTranslation.js'; export default function GeneralSection() { @@ -82,6 +83,10 @@ export default function GeneralSection() {
+ + + +
kies(e.target.value)}> + + + + + + {(o) => } + + + + + + {(o) => } + + + + +
+ ); +} diff --git a/open-pdf-studio/js/solid/stores/annotationLayersStore.js b/open-pdf-studio/js/solid/stores/annotationLayersStore.js index 9f8689968..c1dbe86ce 100644 --- a/open-pdf-studio/js/solid/stores/annotationLayersStore.js +++ b/open-pdf-studio/js/solid/stores/annotationLayersStore.js @@ -19,6 +19,7 @@ import { getLayers, findLayer, layerDisplayName, nextLayerName, annotationsOnLayer, assignLayer, setCurrentLayer, layerIdOf, annotationsInDocument, } from '../../annotations/annotatie-lagen.js'; +import { annotatieLaagIdVoorOcg, ocgVoorAnnotatieLaagId } from '../../pdf/laag-ids.js'; const [panelVisible, setPanelVisible] = createSignal(false); const [layersVersion, setLayersVersion] = createSignal(0); @@ -115,7 +116,15 @@ export function setAnnotationLayerFlag(id, sleutel, waarde) { if (!['visible', 'printable', 'locked'].includes(sleutel)) return; const doc = getActiveDocument(); if (!doc) return; - if (updateLayer(doc, id, { [sleutel]: !!waarde }).ok) naWijziging(doc); + if (!updateLayer(doc, id, { [sleutel]: !!waarde }).ok) return; + naWijziging(doc); + // Een laag van de tekening (#377): de inhoud van de tekening gaat mee aan + // of uit, zoals in het paneel Lagen. + const ocg = sleutel === 'visible' ? ocgVoorAnnotatieLaagId(id) : null; + if (ocg) { + import('../../ui/panels/layers.js').then((m) => m.toggleLayer(ocg, !!waarde)) + .catch((e) => console.warn('[lagen] laag van de tekening niet geschakeld:', e)); + } } export function setAnnotationLayerColor(id, kleur) { @@ -230,6 +239,35 @@ export async function moveAnnotationsToAnnotationLayer(anns, id) { return n; } +/** + * Markeringen op een laag van de tekening zetten (#377): de laag wordt een + * annotatielaag met dezelfde OCG (id 'ocg--', zie + * saver/annotatie-lagen.js), zodat de markeringen met de laag aan en uit + * gaan en bij het opslaan /OC naar die OCG krijgen. + * @param {object[]} anns + * @param {string} ocgId laag-id van pdf.js (`12R`) + * @param {{name?: string, visible?: boolean}} [laag] naam en stand in het paneel Lagen + * @returns {Promise} hoeveel markeringen er verplaatst zijn + */ +export async function moveAnnotationsToDrawingLayer(anns, ocgId, { name = '', visible = true } = {}) { + const doc = getActiveDocument(); + const id = annotatieLaagIdVoorOcg(ocgId); + if (!doc || !id) return 0; + const nieuw = !findLayer(doc, id); + if (nieuw) { + const basis = String(name || '').trim() || ocgId; + let r = addLayer(doc, { id, name: basis, visible }); + for (let n = 2; !r.ok && r.error === 'name-taken' && n < 100; n++) { + r = addLayer(doc, { id, name: `${basis} (${n})`, visible }); + } + if (!r.ok) return 0; + } + const n = await moveAnnotationsToAnnotationLayer(anns, id); + // De laag is nieuw in het model: bij het opslaan komt hij in het bestand. + if (nieuw) markeerGewijzigd(); + return n; +} + /** De geselecteerde markeringen naar een laag. */ export function moveSelectionToAnnotationLayer(id) { const doc = getActiveDocument(); diff --git a/open-pdf-studio/js/solid/stores/panels/layersStore.js b/open-pdf-studio/js/solid/stores/panels/layersStore.js index fdb3c6ca1..06860c88f 100644 --- a/open-pdf-studio/js/solid/stores/panels/layersStore.js +++ b/open-pdf-studio/js/solid/stores/panels/layersStore.js @@ -3,9 +3,15 @@ import { createSignal } from 'solid-js'; const [items, setItems] = createSignal([]); const [countText, setCountText] = createSignal('0 layers'); const [emptyMessage, setEmptyMessage] = createSignal('No layers in this document'); +// "Als standaard instellen" (#377): kan het (de stand wijkt af van die in het +// bestand) en staat het al klaar voor de volgende keer opslaan. +const [canSetDefault, setCanSetDefault] = createSignal(false); +const [defaultPending, setDefaultPending] = createSignal(false); export { items, setItems, countText, setCountText, emptyMessage, setEmptyMessage, + canSetDefault, setCanSetDefault, + defaultPending, setDefaultPending, }; diff --git a/open-pdf-studio/js/solid/stores/propertiesStore.js b/open-pdf-studio/js/solid/stores/propertiesStore.js index 66bd345cf..b86d6bae0 100644 --- a/open-pdf-studio/js/solid/stores/propertiesStore.js +++ b/open-pdf-studio/js/solid/stores/propertiesStore.js @@ -1,3 +1,4 @@ +import { laagKeuzeWaarde, leesLaagKeuze } from '../../annotations/laag-keuze.js'; import { createSignal, batch } from 'solid-js'; import { DRAFTING_RULE_TYPES, effectiveDraftingLineWidth } from '../../annotations/drafting-rules.js'; import { createStore } from 'solid-js/store'; @@ -99,6 +100,8 @@ const [annotProps, setAnnotProps] = createStore({ printable: true, readOnly: false, marked: false, + // Laag (#377): de waarde van de keuzelijst, zie annotations/laag-keuze.js. + layer: '', altText: '', status: 'none', statusBy: '', @@ -411,6 +414,7 @@ export function storeShowProperties(annotation) { printable: annotation.printable !== false, readOnly: annotation.readOnly || false, marked: annotation.marked || false, + layer: laagKeuzeWaarde(annotation, getActiveDocument()), altText: annotation.altText || '', status: annotation.status || 'none', statusBy: annotation.statusBy || '', @@ -748,6 +752,7 @@ export function storeShowMultiSelection(selected) { printable: sharedPrintable, readOnly: sharedReadOnly, marked: sharedMarked, + layer: sharedValue(selected, a => laagKeuzeWaarde(a, getActiveDocument()), 'mixed'), altText: sharedAltText, status: sharedStatus, color: sharedColor, @@ -2109,6 +2114,26 @@ export function getLineWidthLabel() { } // Get the current annotation reference +/** + * Keuzelijst "Laag" (#377): de selectie naar een markeringslaag of een laag + * van de tekening. Eén undo-stap (annotationLayersStore). + * @param {string} waarde 'a:' of 'o:' + * @param {{name?: string, visible?: boolean}} [tekeningLaag] bij 'o:' + */ +export async function setAnnotationLayerProp(waarde, tekeningLaag = {}) { + const keuze = leesLaagKeuze(waarde); + const doc = getActiveDocument(); + if (!keuze || !doc) return; + const selectie = doc.selectedAnnotations?.length ? [...doc.selectedAnnotations] + : (currentAnnotation ? [currentAnnotation] : []); + const te = selectie.filter(a => !a.locked); + if (!te.length) return; + const lagen = await import('./annotationLayersStore.js'); + if (keuze.soort === 'tekening') await lagen.moveAnnotationsToDrawingLayer(te, keuze.id, tekeningLaag); + else await lagen.moveAnnotationsToAnnotationLayer(te, keuze.id); + setAnnotProps('layer', sharedValue(te, a => laagKeuzeWaarde(a, doc), 'mixed')); +} + export function getCurrentAnnotation() { return currentAnnotation; } diff --git a/open-pdf-studio/js/ui/panels/lagen-lijst.js b/open-pdf-studio/js/ui/panels/lagen-lijst.js index 610877ec4..d42dbc1ce 100644 --- a/open-pdf-studio/js/ui/panels/lagen-lijst.js +++ b/open-pdf-studio/js/ui/panels/lagen-lijst.js @@ -9,15 +9,25 @@ // `visible` is de stand in de meegegeven config: de beginstand van het // document (PDFium tekent een laag die daar uit staat ook niet), of de stand // na schakelen in het paneel (#684). +// +// Met `boom` (pdf/lagen-boom.js, uit het bestand gelezen) volgt de lijst de +// boom van /Order zoals de PDF-standaard hem bedoelt: ook lagen onder een +// andere laag (een array zonder label na een laag), wat pdf.js weglaat. De +// boom zegt ook welke lagen vergrendeld zijn (/Locked: geen vinkje om te +// schakelen) en welke in een keuzegroep staan (/RBGroups: één aan zet de +// andere uit). Namen en stand komen altijd uit de config van pdf.js. /** * @param {object|null} config de OptionalContentConfig van pdf.js - * @param {{overslaan?: Set}} [opties] id's die hier niet horen - * (de markeringslagen van de app, die hun eigen paneel hebben) - * @returns {({id: string, name: string, visible: boolean, depth: number} + * @param {{overslaan?: Set, boom?: object|null}} [opties] overslaan: + * id's die hier niet horen (de markeringslagen van de app, die hun eigen + * paneel hebben); boom: uit leesLagenBoom, of null + * @returns {({id: string, name: string, visible: boolean, depth: number, + * locked?: true, radio?: true} * | {kop: true, name: string, depth: number})[]} */ -export function lagenLijst(config, { overslaan = new Set() } = {}) { +export function lagenLijst(config, { overslaan = new Set(), boom = null } = {}) { + if (boom && config) return lijstUitBoom(config, boom, overslaan); if (!config) return []; const groepen = new Map(config); if (!groepen.size) return []; @@ -53,3 +63,45 @@ export function lagenLijst(config, { overslaan = new Set() } = {}) { } return regels; } + +function lijstUitBoom(config, boom, overslaan) { + const groepen = new Map(config); + if (!groepen.size) return []; + const radio = new Set(boom.rbGroups.flat()); + const gehad = new Set(); + let volgnummer = 0; + const laag = (id, depth) => { + gehad.add(id); + volgnummer += 1; + const groep = groepen.get(id); + const regel = { id, name: groep.name || boom.ocgs.get(id)?.name || `Layer ${volgnummer}`, visible: groep.visible !== false, depth }; + if (boom.locked.has(id)) regel.locked = true; + if (radio.has(id)) regel.radio = true; + return regel; + }; + const regelsVan = (knopen, depth) => { + const regels = []; + for (const knoop of knopen || []) { + if ('id' in knoop) { + if (!groepen.has(knoop.id) || gehad.has(knoop.id)) continue; + const toon = !overslaan.has(knoop.id); + // Een overgeslagen laag laat zijn kinderen op zijn eigen niveau staan. + if (toon) regels.push(laag(knoop.id, depth)); + else gehad.add(knoop.id); + regels.push(...regelsVan(knoop.children, toon ? depth + 1 : depth)); + } else if (!knoop.label) { + regels.push(...regelsVan(knoop.children, depth)); + } else { + const onder = regelsVan(knoop.children, depth + 1); + if (!onder.length) continue; + regels.push({ kop: true, name: knoop.label, depth }, ...onder); + } + } + return regels; + }; + const regels = regelsVan(boom.order, 0); + for (const id of groepen.keys()) { + if (!gehad.has(id) && !overslaan.has(id)) regels.push(laag(id, 0)); + } + return regels; +} diff --git a/open-pdf-studio/js/ui/panels/layers.js b/open-pdf-studio/js/ui/panels/layers.js index 8fa18fa34..a401621aa 100644 --- a/open-pdf-studio/js/ui/panels/layers.js +++ b/open-pdf-studio/js/ui/panels/layers.js @@ -1,17 +1,38 @@ import i18next from '../../i18n/config.js'; import { getActiveDocument } from '../../core/state.js'; -import { setLayerItems as setItems, setLayerCountText as setCountText, setLayerEmptyMessage as setEmptyMessage } from '../../bridge.js'; +import { + setLayerItems as setItems, setLayerCountText as setCountText, setLayerEmptyMessage as setEmptyMessage, + setLayerDefaultPending as setDefaultPending, setLayerCanSetDefault as setCanSetDefault, + annotationLayersChanged, +} from '../../bridge.js'; import { lagenLijst } from './lagen-lijst.js'; -import { eigenLaagConfig, vergeetVerouderdeStand, zetLaagZichtbaar } from '../../pdf/lagen-schakelen.js'; +import { + eigenLaagConfig, vergeetVerouderdeStand, zetLaagZichtbaar, lagenBoom, + heeftAfwijkendeStand, markeerStandAlsStandaard, +} from '../../pdf/lagen-schakelen.js'; +import { getLayers, updateLayer } from '../../annotations/annotatie-lagen.js'; +import { ocgVoorAnnotatieLaagId } from '../../pdf/laag-ids.js'; function geenLagen(melding) { setItems([]); setCountText(i18next.t('leftPanel.layersCount', { count: 0 })); setEmptyMessage(melding); + setCanSetDefault(false); + setDefaultPending(false); +} + +/** De stand van het document: de eigen stand na schakelen, anders de beginstand. */ +async function laagConfig(doc) { + return eigenLaagConfig(doc) || (typeof doc.pdfDoc.getOptionalContentConfig === 'function' + ? await doc.pdfDoc.getOptionalContentConfig() + : null); } // Het paneel toont de lagen en hun stand: de beginstand van het document, of -// de stand na het schakelen (#684, zie pdf/lagen-schakelen.js). +// de stand na het schakelen (#684, zie pdf/lagen-schakelen.js). De boom komt +// uit het bestand (pdf/lagen-boom.js, #377): is die niet meteen gelezen, dan +// staat de lijst er eerst in de volgorde van pdf.js en daarna in die van +// /Order, met vergrendelde lagen en keuzegroepen. export async function updateLayersList() { const activeDoc = getActiveDocument(); if (!activeDoc || !activeDoc.pdfDoc) { @@ -24,34 +45,94 @@ export async function updateLayersList() { try { const pdfDoc = activeDoc.pdfDoc; await vergeetVerouderdeStand(activeDoc); - const ocConfig = eigenLaagConfig(activeDoc) || (typeof pdfDoc.getOptionalContentConfig === 'function' - ? await pdfDoc.getOptionalContentConfig() - : null); - // De annotatielagen van deze app zijn ook OCG's (#468), maar staan in het - // paneel Markeringslagen; hier alleen de lagen van de tekening zelf. - const regels = lagenLijst(ocConfig, { overslaan: activeDoc._annotatieLaagOcgIds || new Set() }); - const aantal = regels.filter((r) => !r.kop).length; - if (!aantal) { - geenLagen(i18next.t('leftPanel.noLayers')); + const toon = async (boom) => { + const ocConfig = await laagConfig(activeDoc); + // De annotatielagen van deze app zijn ook OCG's (#468), maar staan in het + // paneel Markeringslagen; hier alleen de lagen van de tekening zelf. + const regels = lagenLijst(ocConfig, { overslaan: activeDoc._annotatieLaagOcgIds || new Set(), boom }); + const aantal = regels.filter((r) => !r.kop).length; + if (!aantal) { + geenLagen(i18next.t('leftPanel.noLayers')); + return false; + } + setEmptyMessage(null); + setItems(regels); + setCountText(i18next.t('leftPanel.layersCount', { count: aantal })); + setCanSetDefault(heeftAfwijkendeStand(activeDoc)); + setDefaultPending(!!activeDoc._laagStandBewaren); + return true; + }; + const boomBelofte = lagenBoom(activeDoc); + const eerst = await Promise.race([boomBelofte, new Promise((r) => { setTimeout(() => r(undefined), 150); })]); + if (eerst !== undefined) { + await toon(eerst); return; } - setEmptyMessage(null); - setItems(regels); - setCountText(i18next.t('leftPanel.layersCount', { count: aantal })); + if (!(await toon(null))) return; + const boom = await boomBelofte; + if (boom && getActiveDocument() === activeDoc && activeDoc.pdfDoc === pdfDoc) await toon(boom); } catch (e) { console.warn('Failed to load layers:', e); geenLagen(i18next.t('leftPanel.noLayers')); } } +/** + * Markeringen op een laag van de tekening (#377) hebben een annotatielaag + * `ocg--` met dezelfde OCG. Zijn zichtbaarheid volgt de + * laag in dit paneel, zodat die markeringen met hun laag aan en uit gaan. + * @returns {boolean} of er een annotatielaag veranderde + */ +export function volgAnnotatieLagen(doc, config) { + if (!doc || !config) return false; + let veranderd = false; + for (const laag of getLayers(doc)) { + const ocg = ocgVoorAnnotatieLaagId(laag.id); + const groep = ocg ? config.getGroup?.(ocg) : null; + if (!groep) continue; + const zichtbaar = groep.visible !== false; + if (laag.visible === zichtbaar) continue; + if (updateLayer(doc, laag.id, { visible: zichtbaar }).ok) veranderd = true; + } + return veranderd; +} + /** Vinkje in het paneel: laag aan of uit, daarna de lijst opnieuw (#684). */ export async function toggleLayer(id, zichtbaar) { const activeDoc = getActiveDocument(); if (!activeDoc?.pdfDoc) return; try { + const boom = await lagenBoom(activeDoc); + // Vergrendeld (/Locked): niet te schakelen. + if (boom?.locked?.has(id)) { + await updateLayersList(); + return; + } await zetLaagZichtbaar(activeDoc, id, zichtbaar); + if (volgAnnotatieLagen(activeDoc, eigenLaagConfig(activeDoc))) { + await annotationLayersChanged({ modified: false }); + } } catch (e) { console.warn('[lagen] schakelen mislukt:', e); } await updateLayersList(); } + +/** + * "Als standaard instellen": de huidige stand gaat bij de volgende keer + * opslaan als standaardconfiguratie (/D) het bestand in (#377). + * @returns {Promise} of er iets klaarstaat + */ +export async function setLayerStateAsDefault() { + const activeDoc = getActiveDocument(); + if (!activeDoc?.pdfDoc) return false; + if (!markeerStandAlsStandaard(activeDoc)) return false; + try { + const { markDocumentModified } = await import('../chrome/tabs.js'); + markDocumentModified(activeDoc); + } catch (e) { + console.warn('[lagen] document niet als gewijzigd gemarkeerd:', e); + } + await updateLayersList(); + return true; +} diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index 25479a630..74e74313a 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -511,6 +511,10 @@ scripts/gehost-html.test.mjs js/core/extern-openen.test.mjs js/search/find-bar-resultaten.test.mjs js/pdf/laagstand.test.mjs +js/pdf/lagen-boom.test.mjs +js/pdf/lagen-render.test.mjs +js/pdf/saver/annotatie-lagen-tekening.test.mjs +js/annotations/laag-keuze.test.mjs js/pdf/vul-dekking-rondgang.test.mjs js/symbols/profieltype.test.mjs js/tools/editor-ondergrond.test.mjs diff --git a/open-pdf-studio/styles/panels.css b/open-pdf-studio/styles/panels.css index 2270ec6e3..d27607838 100644 --- a/open-pdf-studio/styles/panels.css +++ b/open-pdf-studio/styles/panels.css @@ -989,6 +989,55 @@ text-overflow: ellipsis; } +/* Vergrendelde laag (/Locked): vinkje grijs, slotje erachter. */ +.layer-list-locked .layer-list-name { + color: var(--theme-panel-tab-text); +} + +.layer-list-lock { + width: 12px; + height: 12px; + flex-shrink: 0; + color: var(--theme-panel-tab-text); +} + +/* Werkbalk van het paneel Lagen: "Als standaard instellen" (#377). */ +.layers-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--theme-panel-border); + background: var(--theme-panel-tab-bg); + flex-shrink: 0; +} + +.layers-toolbar-btn { + font-size: 11px; + padding: 2px 8px; + background: var(--theme-panel-bg); + border: 1px solid var(--theme-panel-border); + color: var(--theme-panel-header-text); + cursor: default; +} + +.layers-toolbar-btn:hover:not(:disabled) { + background: var(--theme-panel-tab-hover); +} + +.layers-toolbar-btn:disabled { + opacity: 0.45; +} + +.layers-toolbar-note { + font-size: 11px; + color: var(--theme-panel-tab-text); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .layers-count { font-size: 11px; color: var(--theme-panel-tab-text); From 73b3ebdaaa32eba9fb6b08ffbba496e10c0ddcae Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 10:11:30 +0100 Subject: [PATCH 04/13] feat(attachments): file attachment annotations and attachments panel (#771) Adds the paperclip annotation (PDF /FileAttachment): a ribbon tool that places an icon and embeds a picked file, with select, move and delete, properties (icon, description, file name and size, Open and Save As) and double-click to open. The saver writes /FS with an embedded file, /AF and its own appearance; the loader reads attachment annotations written by other tools, keeping undecodable streams raw. The attachments panel also lists page attachments and can open, save and delete them, and opening an executable attachment asks for confirmation first. Fixes #376 Review fixes on top: - The new ribbon button is added to the ribbon command list used for keyboard shortcuts. - A loaded attachment annotation whose content cannot be read is no longer rewritten without its embedded file: when it was not edited the original annotation stays untouched (file specification, unknown keys, /AP and popup), and when it was edited the new annotation reuses the original file specification. - Attachment content in the store is released when its document closes, unless another open document or the clipboard still uses it. - The confirmation list for executable attachments is extended (.msc, .chm, .url, .iso, .lnk, .scf, .application, .inf, .vhd, scripts and others), trailing dots and spaces no longer bypass it, and temp file names drop characters Windows does not allow. --- .../js/annotations/bijlage-iconen.js | 160 ++++++++ .../js/annotations/bijlage-store.js | 124 ++++++ open-pdf-studio/js/annotations/geometry.js | 3 + open-pdf-studio/js/annotations/handles.js | 2 + open-pdf-studio/js/annotations/rendering.js | 9 + .../js/annotations/rendering/selection.js | 9 + .../keybindings/ribbon-commands.generated.js | 1 + .../js/core/stores/selection-helpers.ts | 1 + .../js/i18n/bijlagen-i18n.test.mjs | 44 +++ .../js/i18n/locales/ar/common.json | 7 +- .../js/i18n/locales/ar/properties.json | 16 +- .../js/i18n/locales/ar/ribbon.json | 4 +- .../js/i18n/locales/bg/common.json | 7 +- .../js/i18n/locales/bg/properties.json | 16 +- .../js/i18n/locales/bg/ribbon.json | 4 +- .../js/i18n/locales/bn/common.json | 7 +- .../js/i18n/locales/bn/properties.json | 16 +- .../js/i18n/locales/bn/ribbon.json | 4 +- .../js/i18n/locales/ca/common.json | 7 +- .../js/i18n/locales/ca/properties.json | 16 +- .../js/i18n/locales/ca/ribbon.json | 4 +- .../js/i18n/locales/cs/common.json | 7 +- .../js/i18n/locales/cs/properties.json | 16 +- .../js/i18n/locales/cs/ribbon.json | 4 +- .../js/i18n/locales/da/common.json | 7 +- .../js/i18n/locales/da/properties.json | 16 +- .../js/i18n/locales/da/ribbon.json | 4 +- .../js/i18n/locales/de/common.json | 7 +- .../js/i18n/locales/de/properties.json | 16 +- .../js/i18n/locales/de/ribbon.json | 4 +- .../js/i18n/locales/el/common.json | 7 +- .../js/i18n/locales/el/properties.json | 16 +- .../js/i18n/locales/el/ribbon.json | 4 +- .../js/i18n/locales/en/common.json | 7 +- .../js/i18n/locales/en/properties.json | 16 +- .../js/i18n/locales/en/ribbon.json | 4 +- .../js/i18n/locales/es/common.json | 7 +- .../js/i18n/locales/es/properties.json | 16 +- .../js/i18n/locales/es/ribbon.json | 4 +- .../js/i18n/locales/fa/common.json | 7 +- .../js/i18n/locales/fa/properties.json | 16 +- .../js/i18n/locales/fa/ribbon.json | 4 +- .../js/i18n/locales/fi/common.json | 7 +- .../js/i18n/locales/fi/properties.json | 16 +- .../js/i18n/locales/fi/ribbon.json | 4 +- .../js/i18n/locales/fr/common.json | 7 +- .../js/i18n/locales/fr/properties.json | 16 +- .../js/i18n/locales/fr/ribbon.json | 4 +- .../js/i18n/locales/he/common.json | 7 +- .../js/i18n/locales/he/properties.json | 16 +- .../js/i18n/locales/he/ribbon.json | 4 +- .../js/i18n/locales/hi/common.json | 7 +- .../js/i18n/locales/hi/properties.json | 16 +- .../js/i18n/locales/hi/ribbon.json | 4 +- .../js/i18n/locales/hr/common.json | 7 +- .../js/i18n/locales/hr/properties.json | 16 +- .../js/i18n/locales/hr/ribbon.json | 4 +- .../js/i18n/locales/hu/common.json | 7 +- .../js/i18n/locales/hu/properties.json | 16 +- .../js/i18n/locales/hu/ribbon.json | 4 +- .../js/i18n/locales/id/common.json | 7 +- .../js/i18n/locales/id/properties.json | 16 +- .../js/i18n/locales/id/ribbon.json | 4 +- .../js/i18n/locales/it/common.json | 7 +- .../js/i18n/locales/it/properties.json | 16 +- .../js/i18n/locales/it/ribbon.json | 4 +- .../js/i18n/locales/ja/common.json | 7 +- .../js/i18n/locales/ja/properties.json | 16 +- .../js/i18n/locales/ja/ribbon.json | 4 +- .../js/i18n/locales/ko/common.json | 7 +- .../js/i18n/locales/ko/properties.json | 16 +- .../js/i18n/locales/ko/ribbon.json | 4 +- .../js/i18n/locales/lo/common.json | 7 +- .../js/i18n/locales/lo/properties.json | 16 +- .../js/i18n/locales/lo/ribbon.json | 4 +- .../js/i18n/locales/ms/common.json | 7 +- .../js/i18n/locales/ms/properties.json | 16 +- .../js/i18n/locales/ms/ribbon.json | 4 +- .../js/i18n/locales/nb/common.json | 7 +- .../js/i18n/locales/nb/properties.json | 16 +- .../js/i18n/locales/nb/ribbon.json | 4 +- .../js/i18n/locales/nl/common.json | 7 +- .../js/i18n/locales/nl/properties.json | 16 +- .../js/i18n/locales/nl/ribbon.json | 4 +- .../js/i18n/locales/pl/common.json | 7 +- .../js/i18n/locales/pl/properties.json | 16 +- .../js/i18n/locales/pl/ribbon.json | 4 +- .../js/i18n/locales/pt/common.json | 7 +- .../js/i18n/locales/pt/properties.json | 16 +- .../js/i18n/locales/pt/ribbon.json | 4 +- .../js/i18n/locales/ro/common.json | 7 +- .../js/i18n/locales/ro/properties.json | 16 +- .../js/i18n/locales/ro/ribbon.json | 4 +- .../js/i18n/locales/ru/common.json | 7 +- .../js/i18n/locales/ru/properties.json | 16 +- .../js/i18n/locales/ru/ribbon.json | 4 +- .../js/i18n/locales/sk/common.json | 7 +- .../js/i18n/locales/sk/properties.json | 16 +- .../js/i18n/locales/sk/ribbon.json | 4 +- .../js/i18n/locales/sr/common.json | 7 +- .../js/i18n/locales/sr/properties.json | 16 +- .../js/i18n/locales/sr/ribbon.json | 4 +- .../js/i18n/locales/sv/common.json | 7 +- .../js/i18n/locales/sv/properties.json | 16 +- .../js/i18n/locales/sv/ribbon.json | 4 +- .../js/i18n/locales/sw/common.json | 7 +- .../js/i18n/locales/sw/properties.json | 16 +- .../js/i18n/locales/sw/ribbon.json | 4 +- .../js/i18n/locales/ta/common.json | 7 +- .../js/i18n/locales/ta/properties.json | 16 +- .../js/i18n/locales/ta/ribbon.json | 4 +- .../js/i18n/locales/th/common.json | 7 +- .../js/i18n/locales/th/properties.json | 16 +- .../js/i18n/locales/th/ribbon.json | 4 +- .../js/i18n/locales/tr/common.json | 7 +- .../js/i18n/locales/tr/properties.json | 16 +- .../js/i18n/locales/tr/ribbon.json | 4 +- .../js/i18n/locales/uk/common.json | 7 +- .../js/i18n/locales/uk/properties.json | 16 +- .../js/i18n/locales/uk/ribbon.json | 4 +- .../js/i18n/locales/ur/common.json | 7 +- .../js/i18n/locales/ur/properties.json | 16 +- .../js/i18n/locales/ur/ribbon.json | 4 +- .../js/i18n/locales/vi/common.json | 7 +- .../js/i18n/locales/vi/properties.json | 16 +- .../js/i18n/locales/vi/ribbon.json | 4 +- .../js/i18n/locales/zh/common.json | 7 +- .../js/i18n/locales/zh/properties.json | 16 +- .../js/i18n/locales/zh/ribbon.json | 4 +- open-pdf-studio/js/pdf/bijlage-annotatie.js | 162 ++++++++ .../js/pdf/bijlage-annotatie.test.mjs | 356 ++++++++++++++++++ .../js/pdf/bijlage-rondgang.test.mjs | 292 ++++++++++++++ open-pdf-studio/js/pdf/bijlagen.js | 178 ++++++++- open-pdf-studio/js/pdf/bijlagen.test.mjs | 27 ++ open-pdf-studio/js/pdf/document-release.js | 12 +- open-pdf-studio/js/pdf/loader.js | 6 +- .../js/pdf/loader/annotatie-opmerking.js | 2 +- .../js/pdf/loader/annotation-converter.js | 45 ++- .../js/pdf/loader/color-extraction.js | 39 +- open-pdf-studio/js/pdf/saver.js | 73 +++- open-pdf-studio/js/pdf/saver/ruwe-sleutels.js | 9 + .../properties-panel/AppearanceSection.jsx | 30 +- .../properties-panel/ContentSection.jsx | 33 ++ .../js/solid/components/ribbon/CommentTab.jsx | 4 +- open-pdf-studio/js/solid/data/ribbonIcons.js | 2 + .../js/solid/stores/propertiesStore.js | 19 +- open-pdf-studio/js/tools/tool-dispatcher.js | 8 + open-pdf-studio/js/tools/tools/index.js | 3 +- open-pdf-studio/js/tools/tools/select-tool.js | 7 +- open-pdf-studio/js/tools/tools/text-tool.js | 15 + open-pdf-studio/js/types/annotation.ts | 2 +- open-pdf-studio/js/ui/bijlage-annotaties.js | 124 ++++++ open-pdf-studio/js/ui/chrome/tabs.js | 4 +- open-pdf-studio/js/ui/panels/attachments.js | 141 +++++-- open-pdf-studio/scripts/unit-testlijst.txt | 3 + open-pdf-studio/styles/properties-panel.css | 6 + 156 files changed, 2801 insertions(+), 234 deletions(-) create mode 100644 open-pdf-studio/js/annotations/bijlage-iconen.js create mode 100644 open-pdf-studio/js/annotations/bijlage-store.js create mode 100644 open-pdf-studio/js/i18n/bijlagen-i18n.test.mjs create mode 100644 open-pdf-studio/js/pdf/bijlage-annotatie.js create mode 100644 open-pdf-studio/js/pdf/bijlage-annotatie.test.mjs create mode 100644 open-pdf-studio/js/pdf/bijlage-rondgang.test.mjs create mode 100644 open-pdf-studio/js/ui/bijlage-annotaties.js diff --git a/open-pdf-studio/js/annotations/bijlage-iconen.js b/open-pdf-studio/js/annotations/bijlage-iconen.js new file mode 100644 index 000000000..c39516962 --- /dev/null +++ b/open-pdf-studio/js/annotations/bijlage-iconen.js @@ -0,0 +1,160 @@ +// Pictogrammen van een bijlage-annotatie (/FileAttachment, #376). +// +// De PDF-specificatie kent vier namen (/Name): PushPin, Paperclip, Graph en +// Tag. Elk pictogram is hier één keer beschreven, in een eenheidsvak (0..1, +// y omlaag), en wordt zowel op het canvas getekend als in de appearance- +// stroom van het bestand geschreven, zodat andere lezers hetzelfde zien. +// +// Puur: geen app-imports, ook in node te testen. + +/** App-naam -> /Name in het bestand. */ +export const BIJLAGE_ICOON_PDF = { + paperclip: 'Paperclip', + pushpin: 'PushPin', + graph: 'Graph', + tag: 'Tag', +}; + +export const BIJLAGE_ICONEN = Object.keys(BIJLAGE_ICOON_PDF); +export const STANDAARD_BIJLAGE_ICOON = 'paperclip'; +export const STANDAARD_BIJLAGE_KLEUR = '#2B579A'; +export const BIJLAGE_ICOON_MAAT = 24; + +/** /Name uit het bestand -> app-naam; onbekend wordt de paperclip. */ +export function bijlageIcoonUitPdf(naam) { + const n = String(naam || '').replace(/^\//, '').toLowerCase(); + return BIJLAGE_ICOON_PDF[n] ? n : (n === 'push pin' ? 'pushpin' : STANDAARD_BIJLAGE_ICOON); +} + +export function bijlageIcoonNaarPdf(icoon) { + return BIJLAGE_ICOON_PDF[icoon] || BIJLAGE_ICOON_PDF[STANDAARD_BIJLAGE_ICOON]; +} + +// Cirkel als vier bezierbogen (k = 0,5523 van de straal). +function cirkel(cx, cy, r) { + const k = 0.5523 * r; + return [ + ['M', cx + r, cy], + ['C', cx + r, cy + k, cx + k, cy + r, cx, cy + r], + ['C', cx - k, cy + r, cx - r, cy + k, cx - r, cy], + ['C', cx - r, cy - k, cx - k, cy - r, cx, cy - r], + ['C', cx + k, cy - r, cx + r, cy - k, cx + r, cy], + ['Z'], + ]; +} + +/** + * Per pictogram de deelpaden. `modus`: 'stroke', 'fill' of 'fillStroke'; + * `wit`: in wit in plaats van de annotatiekleur. + */ +const PADEN = { + paperclip: [{ + modus: 'stroke', + d: [ + ['M', 0.62, 0.30], + ['L', 0.62, 0.72], + ['C', 0.62, 0.86, 0.42, 0.86, 0.42, 0.72], + ['L', 0.42, 0.22], + ['C', 0.42, 0.04, 0.76, 0.04, 0.76, 0.22], + ['L', 0.76, 0.76], + ['C', 0.76, 1.00, 0.28, 1.00, 0.28, 0.76], + ['L', 0.28, 0.34], + ], + }], + pushpin: [ + { + modus: 'fillStroke', + d: [ + ['M', 0.34, 0.08], ['L', 0.66, 0.08], ['L', 0.60, 0.20], ['L', 0.60, 0.40], + ['L', 0.76, 0.56], ['L', 0.24, 0.56], ['L', 0.40, 0.40], ['L', 0.40, 0.20], ['Z'], + ], + }, + { modus: 'stroke', d: [['M', 0.50, 0.56], ['L', 0.50, 0.94]] }, + ], + graph: [ + { modus: 'stroke', d: [['M', 0.14, 0.10], ['L', 0.14, 0.86], ['L', 0.90, 0.86]] }, + { modus: 'fill', d: [['M', 0.26, 0.56], ['L', 0.38, 0.56], ['L', 0.38, 0.80], ['L', 0.26, 0.80], ['Z']] }, + { modus: 'fill', d: [['M', 0.46, 0.36], ['L', 0.58, 0.36], ['L', 0.58, 0.80], ['L', 0.46, 0.80], ['Z']] }, + { modus: 'fill', d: [['M', 0.66, 0.18], ['L', 0.78, 0.18], ['L', 0.78, 0.80], ['L', 0.66, 0.80], ['Z']] }, + ], + tag: [ + { + modus: 'fillStroke', + d: [['M', 0.10, 0.10], ['L', 0.52, 0.10], ['L', 0.92, 0.50], ['L', 0.52, 0.90], ['L', 0.10, 0.50], ['Z']], + }, + { modus: 'fill', wit: true, d: cirkel(0.30, 0.30, 0.07) }, + ], +}; + +/** De deelpaden van een pictogram (een onbekende naam geeft de paperclip). */ +export function bijlageIcoonPaden(icoon) { + return PADEN[icoon] || PADEN[STANDAARD_BIJLAGE_ICOON]; +} + +function hexNaarRgb(hex) { + const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '')); + if (!m) return [0.17, 0.34, 0.6]; + const v = parseInt(m[1], 16); + return [(v >> 16) & 255, (v >> 8) & 255, v & 255].map((c) => c / 255); +} + +const getal = (n) => { + const r = Math.round(n * 1000) / 1000; + return Object.is(r, -0) ? '0' : String(r); +}; + +/** + * De inhoud van de appearance-stroom: het pictogram in een vak van w x h + * (PDF-ruimte, y omhoog, oorsprong linksonder van de /BBox). + */ +export function bijlageIcoonContent(icoon, w, h, kleur = STANDAARD_BIJLAGE_KLEUR) { + const [r, g, b] = hexNaarRgb(kleur); + const s = Math.min(w, h); + const ox = (w - s) / 2; + const oy = (h - s) / 2; + const px = (ux) => getal(ox + ux * s); + const py = (uy) => getal(oy + (1 - uy) * s); + const kleurOp = `${getal(r)} ${getal(g)} ${getal(b)}`; + const regels = ['q', `${getal(Math.max(0.8, s * 0.08))} w`, '1 J', '1 j']; + for (const pad of bijlageIcoonPaden(icoon)) { + regels.push(pad.wit ? '1 1 1 rg 1 1 1 RG' : `${kleurOp} rg ${kleurOp} RG`); + for (const [op, ...v] of pad.d) { + if (op === 'M') regels.push(`${px(v[0])} ${py(v[1])} m`); + else if (op === 'L') regels.push(`${px(v[0])} ${py(v[1])} l`); + else if (op === 'C') regels.push(`${px(v[0])} ${py(v[1])} ${px(v[2])} ${py(v[3])} ${px(v[4])} ${py(v[5])} c`); + else if (op === 'Z') regels.push('h'); + } + regels.push(pad.modus === 'stroke' ? 'S' : pad.modus === 'fill' ? 'f' : 'B'); + } + regels.push('Q'); + return regels.join('\n'); +} + +/** Tekent het pictogram van een bijlage-annotatie op een 2D-canvas. */ +export function tekenBijlageIcoon(ctx, ann) { + const w = ann.width || BIJLAGE_ICOON_MAAT; + const h = ann.height || BIJLAGE_ICOON_MAAT; + const s = Math.min(w, h); + const ox = ann.x + (w - s) / 2; + const oy = ann.y + (h - s) / 2; + const kleur = ann.color || STANDAARD_BIJLAGE_KLEUR; + ctx.save(); + ctx.lineWidth = Math.max(0.8, s * 0.08); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + for (const pad of bijlageIcoonPaden(ann.icon)) { + ctx.beginPath(); + for (const [op, ...v] of pad.d) { + if (op === 'M') ctx.moveTo(ox + v[0] * s, oy + v[1] * s); + else if (op === 'L') ctx.lineTo(ox + v[0] * s, oy + v[1] * s); + else if (op === 'C') ctx.bezierCurveTo(ox + v[0] * s, oy + v[1] * s, ox + v[2] * s, oy + v[3] * s, ox + v[4] * s, oy + v[5] * s); + else if (op === 'Z') ctx.closePath(); + } + const k = pad.wit ? '#ffffff' : kleur; + ctx.fillStyle = k; + ctx.strokeStyle = k; + if (pad.modus !== 'stroke') ctx.fill(); + if (pad.modus !== 'fill') ctx.stroke(); + } + ctx.restore(); +} diff --git a/open-pdf-studio/js/annotations/bijlage-store.js b/open-pdf-studio/js/annotations/bijlage-store.js new file mode 100644 index 000000000..d8434a5d7 --- /dev/null +++ b/open-pdf-studio/js/annotations/bijlage-store.js @@ -0,0 +1,124 @@ +// De bytes van de bestanden achter bijlage-annotaties (#376). +// +// Het model van een annotatie wordt bij ongedaan maken, kopiëren en in de +// store als JSON gekloond; een Uint8Array overleeft dat niet. De annotatie +// draagt daarom alleen een sleutel (`attachment.key`), de bytes staan hier. +// Een opgeslagen inhoud verandert nooit: een kopie van de annotatie deelt +// dezelfde sleutel zonder dat er iets misgaat. +// +// Een item is óf { bytes } (de echte inhoud), óf { ruw: { bytes, filter, +// decodeParms } } voor een stroom die niet te decoderen was: die gaat bij het +// opslaan ongewijzigd terug het bestand in. +// +// Puur: geen app-imports. + +const items = new Map(); +let teller = 0; + +/** Bewaart een inhoud en geeft de sleutel. */ +export function bewaarBijlageInhoud(item) { + teller += 1; + const key = `bijlage_${Date.now().toString(36)}_${teller.toString(36)}`; + items.set(key, item); + return key; +} + +/** Het item achter een sleutel, of null. */ +export function bijlageItem(key) { + return (key && items.get(key)) || null; +} + +/** De gedecodeerde bytes achter een sleutel, of null. */ +export function bijlageBytes(key) { + return bijlageItem(key)?.bytes || null; +} + +/** Voor tests. */ +export function wisBijlageStore() { + items.clear(); +} + +/** Hoeveel inhouden de store vasthoudt (voor tests en metingen). */ +export function aantalBijlageInhouden() { + return items.size; +} + +/** + * De sleutels waar deze waarden naar wijzen: annotaties met `attachment.key`, + * ook binnen undo-stappen en het klembord (willekeurig genest, begrensd). + * @param {unknown[]} waarden + * @returns {Set} + */ +export function bijlageSleutelsIn(waarden) { + const uit = new Set(); + const gezien = new WeakSet(); + const loop = (v, diepte) => { + if (!v || typeof v !== 'object' || diepte > 8 || gezien.has(v) || ArrayBuffer.isView(v)) return; + gezien.add(v); + if (Array.isArray(v)) { + for (const e of v) loop(e, diepte + 1); + return; + } + const key = v.attachment?.key; + if (typeof key === 'string' && items.has(key)) uit.add(key); + for (const k of Object.keys(v)) { + const e = v[k]; + if (e && typeof e === 'object') loop(e, diepte + 1); + } + }; + for (const w of waarden || []) loop(w, 0); + return uit; +} + +/** + * Bij het sluiten van een document (pdf/document-release.js): de inhoud die + * alleen dit document gebruikte, gaat uit het geheugen. Wat een open + * document of het klembord nog gebruikt (een gekopieerde of geplakte + * bijlage-annotatie deelt de sleutel), blijft. + * @param {object} gesloten het gesloten document + * @param {object[]} overgebleven de documenten die open blijven + * @param {unknown[]} [klembord] wat er op het klembord staat + * @returns {number} aantal vrijgegeven inhouden + */ +export function geefBijlagenVrij(gesloten, overgebleven, klembord = []) { + if (!gesloten || items.size === 0) return 0; + const vanDocument = (d) => [d?.annotations, d?.undoStack, d?.redoStack]; + const weg = bijlageSleutelsIn(vanDocument(gesloten)); + if (weg.size === 0) return 0; + const blijft = bijlageSleutelsIn([...(overgebleven || []).flatMap(vanDocument), ...(klembord || [])]); + let n = 0; + for (const key of weg) { + if (blijft.has(key)) continue; + items.delete(key); + n += 1; + } + return n; +} + +/** + * De inhoud achter een bijlage-annotatie: uit de store, anders via pdf.js + * (`attachment.pdfjsId`, als de lader het bestand nog niet met pdf-lib las). + * Wat pdf.js levert, komt in de store; de annotatie krijgt die sleutel. + * @param {{attachment?: {key?: string|null, pdfjsId?: string|null}}} ann + * @param {{pdfDoc?: {getAttachmentContent?: (id: string) => Promise}}} [doc] + * @returns {Promise} + */ +export async function bijlageInhoud(ann, doc) { + const a = ann?.attachment; + if (!a) return null; + const item = bijlageItem(a.key); + if (item) return item; + if (a.pdfjsId && typeof doc?.pdfDoc?.getAttachmentContent === 'function') { + try { + const b = await doc.pdfDoc.getAttachmentContent(a.pdfjsId); + if (b) { + const nieuw = { bytes: b instanceof Uint8Array ? b : new Uint8Array(b) }; + a.key = bewaarBijlageInhoud(nieuw); + return nieuw; + } + } catch (e) { + console.warn('[bijlage] content could not be read:', e?.message || e); + } + } + return null; +} diff --git a/open-pdf-studio/js/annotations/geometry.js b/open-pdf-studio/js/annotations/geometry.js index 5570de149..33af64c65 100644 --- a/open-pdf-studio/js/annotations/geometry.js +++ b/open-pdf-studio/js/annotations/geometry.js @@ -163,6 +163,7 @@ function getAnnotationCenterAndSize(ann) { height: h }; case 'comment': + case 'fileAttachment': const cw = ann.width || 24; const ch = ann.height || 24; return { @@ -404,6 +405,7 @@ export function findAnnotationAt(x, y, pageNum = null) { break; } case 'comment': + case 'fileAttachment': const cw = ann.width || 24; const ch = ann.height || 24; // Transform click point by inverse rotation if annotation is rotated @@ -762,6 +764,7 @@ export function isPointInsideAnnotation(x, y, annotation) { return Math.sqrt(dcx * dcx + dcy * dcy) <= 11; } case 'comment': + case 'fileAttachment': const commentW = annotation.width || 24; const commentH = annotation.height || 24; return localX >= annotation.x && localX <= annotation.x + commentW && diff --git a/open-pdf-studio/js/annotations/handles.js b/open-pdf-studio/js/annotations/handles.js index 25f0e1d13..bdf09a1e6 100644 --- a/open-pdf-studio/js/annotations/handles.js +++ b/open-pdf-studio/js/annotations/handles.js @@ -124,6 +124,7 @@ function getAnnotationCenter(annotation) { case 'count': return { x: annotation.x, y: annotation.y }; case 'comment': + case 'fileAttachment': const cw = annotation.width || 24; const ch = annotation.height || 24; return { @@ -397,6 +398,7 @@ export function getAnnotationHandles(annotation, scale = 1) { break; case 'comment': + case 'fileAttachment': case 'count': // No resize/rotation handles — fixed-size marker, move only break; diff --git a/open-pdf-studio/js/annotations/rendering.js b/open-pdf-studio/js/annotations/rendering.js index 756a77bf8..ce5f21fe0 100644 --- a/open-pdf-studio/js/annotations/rendering.js +++ b/open-pdf-studio/js/annotations/rendering.js @@ -21,6 +21,7 @@ import { tagWeergaveParams } from '../plattegrond/ruimte-koppeling.js'; import { applyHatchFill, applyHatchFillPolygon } from './rendering/hatch-patterns.js'; import { isVreemdeTelling, tellingStraal } from './telling.js'; import { drawWall } from './rendering/walls.js'; +import { tekenBijlageIcoon } from './bijlage-iconen.js'; import { buildStavenreeks } from './stavenreeks.js'; import { stavenreeksPxPerMm } from './stavenreeks-scale.js'; import { buildBetonbalk } from './betonbalk.js'; @@ -1114,6 +1115,14 @@ export function drawAnnotation(ctx, annotation, snippetBitmaps = null) { break; } + case 'fileAttachment': + // Bijlage-annotatie (#376): het pictogram, zoals in de appearance. + ctx.save(); + ctx.globalAlpha = annotation.opacity ?? 1; + tekenBijlageIcoon(ctx, annotation); + ctx.restore(); + break; + case 'count': { const cx = annotation.x, cy = annotation.y; const col = annotation.color || annotation.strokeColor || '#e11d48'; diff --git a/open-pdf-studio/js/annotations/rendering/selection.js b/open-pdf-studio/js/annotations/rendering/selection.js index cdbe55e15..8a7db022b 100644 --- a/open-pdf-studio/js/annotations/rendering/selection.js +++ b/open-pdf-studio/js/annotations/rendering/selection.js @@ -81,6 +81,15 @@ export function drawSelectionHandles(ctx, annotation) { case 'comment': // No selection indicators — fixed-size icon, move only break; + case 'fileAttachment': + // Bijlage-annotatie (#376): gestippeld kader om het pictogram. + ctx.save(); + ctx.setLineDash([3 / sc, 3 / sc]); + ctx.lineWidth = 1 / sc; + ctx.strokeRect(annotation.x - 2 / sc, annotation.y - 2 / sc, + (annotation.width || 24) + 4 / sc, (annotation.height || 24) + 4 / sc); + ctx.restore(); + break; case 'count': // Telelement: gestippelde ring om de marker zodat de selectie zichtbaar is. ctx.save(); diff --git a/open-pdf-studio/js/core/keybindings/ribbon-commands.generated.js b/open-pdf-studio/js/core/keybindings/ribbon-commands.generated.js index fb8eca39c..098edc72b 100644 --- a/open-pdf-studio/js/core/keybindings/ribbon-commands.generated.js +++ b/open-pdf-studio/js/core/keybindings/ribbon-commands.generated.js @@ -108,6 +108,7 @@ export const RIBBON_COMMANDS = Object.freeze([ {"id":"tool-textbox","tab":"comment","labelKey":"ribbon:comment.textBox"}, {"id":"tool-callout","tab":"comment","labelKey":"ribbon:comment.callout"}, {"id":"tool-comment","tab":"comment","labelKey":"ribbon:comment.note"}, + {"id":"tool-file-attachment","tab":"comment","labelKey":"ribbon:comment.fileAttachment"}, {"id":"tool-proofread","tab":"comment","labelKey":"ribbon:proofread.button"}, {"id":"tool-stamp","tab":"comment","labelKey":"ribbon:comment.stamp"}, {"id":"tool-signature","tab":"comment","labelKey":"ribbon:comment.signature"}, diff --git a/open-pdf-studio/js/core/stores/selection-helpers.ts b/open-pdf-studio/js/core/stores/selection-helpers.ts index b0caa9864..de0187635 100644 --- a/open-pdf-studio/js/core/stores/selection-helpers.ts +++ b/open-pdf-studio/js/core/stores/selection-helpers.ts @@ -150,6 +150,7 @@ export function getAnnotationBounds(ann: Annotation): AnnotationBounds | null { case 'text': return { x: ann.x!, y: ann.y! - (ann.fontSize || 16), width: 100, height: ann.fontSize || 16 }; case 'comment': + case 'fileAttachment': return { x: ann.x!, y: ann.y!, width: ann.width || 24, height: ann.height || 24 }; case 'box': case 'mask': diff --git a/open-pdf-studio/js/i18n/bijlagen-i18n.test.mjs b/open-pdf-studio/js/i18n/bijlagen-i18n.test.mjs new file mode 100644 index 000000000..2a68858d7 --- /dev/null +++ b/open-pdf-studio/js/i18n/bijlagen-i18n.test.mjs @@ -0,0 +1,44 @@ +// De teksten van de bijlagen (#376) staan in elke taal, met dezelfde +// plaatshouders als het Engels. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const map = fileURLToPath(new URL('./locales/', import.meta.url)); +const talen = readdirSync(map); +const json = (taal, ns) => JSON.parse(readFileSync(`${map}${taal}/${ns}.json`, 'utf8')); +const pad = (o, p) => p.split('.').reduce((v, k) => v?.[k], o); + +const SLEUTELS = { + common: ['leftPanel.attachmentOnPage', 'leftPanel.attachmentCreated', 'leftPanel.attachmentModified', + 'leftPanel.openAttachmentRiskTitle', 'leftPanel.openAttachmentRiskMessage'], + ribbon: ['comment.fileAttachment', 'comment.fileAttachmentTitle'], + properties: ['appearance.iconPaperclip', 'appearance.iconPushPin', 'appearance.iconGraph', 'appearance.iconTag', + 'attachment.file', 'attachment.size', 'attachment.open', 'attachment.saveAs', 'attachment.noContent', + 'types.fileAttachment'], +}; +const plaatshouders = (s) => [...String(s).matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort(); + +test('every locale has the attachment strings with the English placeholders', () => { + assert.ok(talen.length >= 40); + for (const [ns, sleutels] of Object.entries(SLEUTELS)) { + const en = json('en', ns); + for (const taal of talen) { + const t = json(taal, ns); + for (const s of sleutels) { + const waarde = pad(t, s); + assert.equal(typeof waarde, 'string', `${taal}/${ns}: ${s}`); + assert.ok(waarde.trim(), `${taal}/${ns}: ${s} is empty`); + assert.deepEqual(plaatshouders(waarde), plaatshouders(pad(en, s)), `${taal}/${ns}: ${s} placeholders`); + } + } + } +}); + +test('non-English locales translate the attachment tool', () => { + const en = json('en', 'ribbon').comment.fileAttachmentTitle; + const gelijk = talen.filter((t) => t !== 'en' && json(t, 'ribbon').comment.fileAttachmentTitle === en); + assert.deepEqual(gelijk, []); +}); diff --git a/open-pdf-studio/js/i18n/locales/ar/common.json b/open-pdf-studio/js/i18n/locales/ar/common.json index ba252c4eb..7362dadf5 100644 --- a/open-pdf-studio/js/i18n/locales/ar/common.json +++ b/open-pdf-studio/js/i18n/locales/ar/common.json @@ -228,7 +228,12 @@ "goToPageLink": "انتقل إلى الصفحة", "openExternalFile": "فتح: {{filename}}", "destination": "الوجهة: {{name}}", - "internalLink": "رابط داخلي" + "internalLink": "رابط داخلي", + "attachmentOnPage": "الصفحة {{page}}", + "attachmentCreated": "تاريخ الإنشاء: {{date}}", + "attachmentModified": "تاريخ التعديل: {{date}}", + "openAttachmentRiskTitle": "فتح المرفق", + "openAttachmentRiskMessage": "يمكن للملف \"{{name}}\" تشغيل برامج على هذا الكمبيوتر. لا تفتحه إلا إذا كنت تثق بمصدره. هل تريد فتحه على أي حال؟" }, "failedToInsertPagesFromFile": "فشل إدراج الصفحات من الملف: {{error}}", "previewPdfRefused": "ملف PDF هذا معاينة من استيراد الرسم وقد يكون غير مكتمل. لا يمكن إدراجه أو حفظه؛ استورد الرسم من جديد.", diff --git a/open-pdf-studio/js/i18n/locales/ar/properties.json b/open-pdf-studio/js/i18n/locales/ar/properties.json index 2d9606472..9a209d03c 100644 --- a/open-pdf-studio/js/i18n/locales/ar/properties.json +++ b/open-pdf-studio/js/i18n/locales/ar/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "مشبك ورق", + "iconPushPin": "دبوس", + "iconGraph": "رسم بياني", + "iconTag": "علامة" }, "stylePresets": { "label": "نمط الخط", @@ -311,7 +315,8 @@ "measurePerimeter": "محيط", "redaction": "تنقيح", "scaleBar": "شريط المقياس", - "parametricSymbol": "رمز بارامتري" + "parametricSymbol": "رمز بارامتري", + "fileAttachment": "مرفق ملف" }, "leftPanel": { "togglePanel": "تبديل اللوحة", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "رأسي متباعد", "inb-horizontaal-breed": "أفقي متباعد" } + }, + "attachment": { + "file": "الملف", + "size": "الحجم", + "open": "فتح", + "saveAs": "حفظ باسم...", + "noContent": "محتوى هذا الملف غير مضمن في ملف PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/ar/ribbon.json b/open-pdf-studio/js/i18n/locales/ar/ribbon.json index c593cb82d..795a55f8b 100644 --- a/open-pdf-studio/js/i18n/locales/ar/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ar/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "تسطيح الكل", "flattenAllTooltip": "تسطيح كل العلامات في محتوى الصفحة", "unflattenAll": "التراجع عن التسطيح", - "unflattenAllTooltip": "استعادة العلامات التي سُطّحت مع السماح بالاستعادة" + "unflattenAllTooltip": "استعادة العلامات التي سُطّحت مع السماح بالاستعادة", + "fileAttachment": "إرفاق ملف", + "fileAttachmentTitle": "مرفق ملف: انقر على الصفحة واختر ملفًا" }, "view": { "pageDisplay": "عرض الصفحة", diff --git a/open-pdf-studio/js/i18n/locales/bg/common.json b/open-pdf-studio/js/i18n/locales/bg/common.json index 1422effb5..699e2b072 100644 --- a/open-pdf-studio/js/i18n/locales/bg/common.json +++ b/open-pdf-studio/js/i18n/locales/bg/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Отиди на страница", "openExternalFile": "Отвори: {{filename}}", "destination": "Дестинация: {{name}}", - "internalLink": "Вътрешна връзка" + "internalLink": "Вътрешна връзка", + "attachmentOnPage": "Страница {{page}}", + "attachmentCreated": "Създаден: {{date}}", + "attachmentModified": "Променен: {{date}}", + "openAttachmentRiskTitle": "Отваряне на прикачения файл", + "openAttachmentRiskMessage": "„{{name}}“ може да стартира програми на този компютър. Отворете го само ако имате доверие на източника. Да се отвори ли въпреки това?" }, "failedToInsertPagesFromFile": "Вмъкването на страници от файл е неуспешно: {{error}}", "previewPdfRefused": "Този PDF е преглед от импортирането на чертежа и може да е непълен. Не може да бъде вмъкнат или записан; импортирайте чертежа отново.", diff --git a/open-pdf-studio/js/i18n/locales/bg/properties.json b/open-pdf-studio/js/i18n/locales/bg/properties.json index d7dc6d6bd..e17c9ea30 100644 --- a/open-pdf-studio/js/i18n/locales/bg/properties.json +++ b/open-pdf-studio/js/i18n/locales/bg/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Кламер", + "iconPushPin": "Карфица", + "iconGraph": "Диаграма", + "iconTag": "Етикет" }, "stylePresets": { "label": "Стил на линия", @@ -311,7 +315,8 @@ "measurePerimeter": "Периметър", "redaction": "Редакция", "scaleBar": "Линеен мащаб", - "parametricSymbol": "Параметричен символ" + "parametricSymbol": "Параметричен символ", + "fileAttachment": "Прикачен файл" }, "leftPanel": { "togglePanel": "Превключване на панела", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Вертикална щриховка рядка", "inb-horizontaal-breed": "Хоризонтална щриховка рядка" } + }, + "attachment": { + "file": "Файл", + "size": "Размер", + "open": "Отваряне", + "saveAs": "Запиши като...", + "noContent": "Съдържанието на този файл не е вградено в PDF файла." } } diff --git a/open-pdf-studio/js/i18n/locales/bg/ribbon.json b/open-pdf-studio/js/i18n/locales/bg/ribbon.json index bd760b456..d5707f1da 100644 --- a/open-pdf-studio/js/i18n/locales/bg/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/bg/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Изравни всички", "flattenAllTooltip": "Изравняване на всички маркировки в съдържанието на страницата", "unflattenAll": "Отмяна на изравняването", - "unflattenAllTooltip": "Възстановяване на маркировки, изравнени с разрешено възстановяване" + "unflattenAllTooltip": "Възстановяване на маркировки, изравнени с разрешено възстановяване", + "fileAttachment": "Прикачване на файл", + "fileAttachmentTitle": "Прикачен файл: щракнете върху страницата и изберете файл" }, "view": { "pageDisplay": "Показване на страница", diff --git a/open-pdf-studio/js/i18n/locales/bn/common.json b/open-pdf-studio/js/i18n/locales/bn/common.json index 04f482d1b..f8bfbfcf9 100644 --- a/open-pdf-studio/js/i18n/locales/bn/common.json +++ b/open-pdf-studio/js/i18n/locales/bn/common.json @@ -228,7 +228,12 @@ "goToPageLink": "পৃষ্ঠায় যান", "openExternalFile": "খুলুন: {{filename}}", "destination": "গন্তব্য: {{name}}", - "internalLink": "অভ্যন্তরীণ লিঙ্ক" + "internalLink": "অভ্যন্তরীণ লিঙ্ক", + "attachmentOnPage": "পৃষ্ঠা {{page}}", + "attachmentCreated": "তৈরি: {{date}}", + "attachmentModified": "পরিবর্তিত: {{date}}", + "openAttachmentRiskTitle": "সংযুক্তি খুলুন", + "openAttachmentRiskMessage": "\"{{name}}\" এই কম্পিউটারে প্রোগ্রাম চালাতে পারে। উৎসের উপর ভরসা থাকলে তবেই এটি খুলুন। তবুও খুলবেন?" }, "failedToInsertPagesFromFile": "ফাইল থেকে পৃষ্ঠা সন্নিবেশ করতে ব্যর্থ: {{error}}", "previewPdfRefused": "এই PDF অঙ্কন আমদানির একটি প্রিভিউ এবং অসম্পূর্ণ হতে পারে। এটি সন্নিবেশ বা সংরক্ষণ করা যাবে না; অঙ্কনটি আবার আমদানি করুন।", diff --git a/open-pdf-studio/js/i18n/locales/bn/properties.json b/open-pdf-studio/js/i18n/locales/bn/properties.json index dbba9581c..f6dba6f18 100644 --- a/open-pdf-studio/js/i18n/locales/bn/properties.json +++ b/open-pdf-studio/js/i18n/locales/bn/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "পেপারক্লিপ", + "iconPushPin": "পুশপিন", + "iconGraph": "গ্রাফ", + "iconTag": "ট্যাগ" }, "stylePresets": { "label": "রেখার শৈলী", @@ -311,7 +315,8 @@ "measurePerimeter": "পরিধি", "redaction": "সংশোধন", "scaleBar": "স্কেল বার", - "parametricSymbol": "প্যারামেট্রিক প্রতীক" + "parametricSymbol": "প্যারামেট্রিক প্রতীক", + "fileAttachment": "ফাইল সংযুক্তি" }, "leftPanel": { "togglePanel": "প্যানেল টগল করুন", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "উল্লম্ব বিরল", "inb-horizontaal-breed": "অনুভূমিক বিরল" } + }, + "attachment": { + "file": "ফাইল", + "size": "আকার", + "open": "খুলুন", + "saveAs": "হিসাবে সংরক্ষণ করুন...", + "noContent": "এই ফাইলের বিষয়বস্তু PDF-এ এম্বেড করা নেই।" } } diff --git a/open-pdf-studio/js/i18n/locales/bn/ribbon.json b/open-pdf-studio/js/i18n/locales/bn/ribbon.json index ae9eb45d8..807652f4f 100644 --- a/open-pdf-studio/js/i18n/locales/bn/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/bn/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "সব সমতল করুন", "flattenAllTooltip": "সব মার্কআপ পৃষ্ঠার বিষয়বস্তুতে সমতল করুন", "unflattenAll": "সমতল করা পূর্বাবস্থায় ফেরান", - "unflattenAllTooltip": "পুনরুদ্ধারের অনুমতি দিয়ে সমতল করা মার্কআপ পুনরুদ্ধার করুন" + "unflattenAllTooltip": "পুনরুদ্ধারের অনুমতি দিয়ে সমতল করা মার্কআপ পুনরুদ্ধার করুন", + "fileAttachment": "ফাইল সংযুক্ত করুন", + "fileAttachmentTitle": "ফাইল সংযুক্তি: পৃষ্ঠায় ক্লিক করুন এবং একটি ফাইল বেছে নিন" }, "view": { "pageDisplay": "পৃষ্ঠা প্রদর্শন", diff --git a/open-pdf-studio/js/i18n/locales/ca/common.json b/open-pdf-studio/js/i18n/locales/ca/common.json index acfc03549..9ffb30f4e 100644 --- a/open-pdf-studio/js/i18n/locales/ca/common.json +++ b/open-pdf-studio/js/i18n/locales/ca/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Vés a la pàgina", "openExternalFile": "Obre: {{filename}}", "destination": "Destinació: {{name}}", - "internalLink": "Enllaç intern" + "internalLink": "Enllaç intern", + "attachmentOnPage": "Pàgina {{page}}", + "attachmentCreated": "Creat: {{date}}", + "attachmentModified": "Modificat: {{date}}", + "openAttachmentRiskTitle": "Obre l'adjunt", + "openAttachmentRiskMessage": "«{{name}}» pot executar programes en aquest ordinador. Obriu-lo només si confieu en l'origen. Voleu obrir-lo igualment?" }, "failedToInsertPagesFromFile": "No s'han pogut inserir pàgines des del fitxer: {{error}}", "previewPdfRefused": "Aquest PDF és una previsualització de la importació del dibuix i pot estar incomplet. No es pot inserir ni desar; torneu a importar el dibuix.", diff --git a/open-pdf-studio/js/i18n/locales/ca/properties.json b/open-pdf-studio/js/i18n/locales/ca/properties.json index 92582a4cb..6eacfca59 100644 --- a/open-pdf-studio/js/i18n/locales/ca/properties.json +++ b/open-pdf-studio/js/i18n/locales/ca/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Clip", + "iconPushPin": "Xinxeta", + "iconGraph": "Gràfic", + "iconTag": "Etiqueta" }, "stylePresets": { "label": "Estil de línia", @@ -311,7 +315,8 @@ "measurePerimeter": "Perímetre", "redaction": "Redacció", "scaleBar": "Barra d'escala", - "parametricSymbol": "Símbol paramètric" + "parametricSymbol": "Símbol paramètric", + "fileAttachment": "Fitxer adjunt" }, "leftPanel": { "togglePanel": "Commuta el panell", @@ -831,5 +836,12 @@ "inb-verticaal-breed": "Vertical ample", "inb-horizontaal-breed": "Horitzontal ample" } + }, + "attachment": { + "file": "Fitxer", + "size": "Mida", + "open": "Obre", + "saveAs": "Desa com a...", + "noContent": "El contingut d'aquest fitxer no està incrustat al PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/ca/ribbon.json b/open-pdf-studio/js/i18n/locales/ca/ribbon.json index aeb7a5770..f7dfa277c 100644 --- a/open-pdf-studio/js/i18n/locales/ca/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ca/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Aplana-ho tot", "flattenAllTooltip": "Aplana totes les marques en el contingut de la pàgina", "unflattenAll": "Desfés l'aplanat", - "unflattenAllTooltip": "Restaura les marques aplanades amb la restauració permesa" + "unflattenAllTooltip": "Restaura les marques aplanades amb la restauració permesa", + "fileAttachment": "Adjunta un fitxer", + "fileAttachmentTitle": "Fitxer adjunt: feu clic a la pàgina i trieu un fitxer" }, "view": { "pageDisplay": "Visualització de pàgina", diff --git a/open-pdf-studio/js/i18n/locales/cs/common.json b/open-pdf-studio/js/i18n/locales/cs/common.json index 71fe43243..34dc339a6 100644 --- a/open-pdf-studio/js/i18n/locales/cs/common.json +++ b/open-pdf-studio/js/i18n/locales/cs/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Přejít na stránku", "openExternalFile": "Otevřít: {{filename}}", "destination": "Cíl: {{name}}", - "internalLink": "Interní odkaz" + "internalLink": "Interní odkaz", + "attachmentOnPage": "Strana {{page}}", + "attachmentCreated": "Vytvořeno: {{date}}", + "attachmentModified": "Změněno: {{date}}", + "openAttachmentRiskTitle": "Otevřít přílohu", + "openAttachmentRiskMessage": "„{{name}}“ může na tomto počítači spouštět programy. Otevřete ji, jen pokud zdroji důvěřujete. Přesto otevřít?" }, "failedToInsertPagesFromFile": "Vložení stránek ze souboru se nezdařilo: {{error}}", "previewPdfRefused": "Toto PDF je náhled z importu výkresu a může být neúplné. Nelze je vložit ani uložit; importujte výkres znovu.", diff --git a/open-pdf-studio/js/i18n/locales/cs/properties.json b/open-pdf-studio/js/i18n/locales/cs/properties.json index e1f8c18e8..9c3053ecc 100644 --- a/open-pdf-studio/js/i18n/locales/cs/properties.json +++ b/open-pdf-studio/js/i18n/locales/cs/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Kancelářská sponka", + "iconPushPin": "Připínáček", + "iconGraph": "Graf", + "iconTag": "Štítek" }, "stylePresets": { "label": "Styl čáry", @@ -311,7 +315,8 @@ "measurePerimeter": "Obvod", "redaction": "Redigování", "scaleBar": "Grafické měřítko", - "parametricSymbol": "Parametrický symbol" + "parametricSymbol": "Parametrický symbol", + "fileAttachment": "Příloha souboru" }, "leftPanel": { "togglePanel": "Přepnout panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Svislá šrafa řídká", "inb-horizontaal-breed": "Vodorovná šrafa řídká" } + }, + "attachment": { + "file": "Soubor", + "size": "Velikost", + "open": "Otevřít", + "saveAs": "Uložit jako...", + "noContent": "Obsah tohoto souboru není vložen do PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/cs/ribbon.json b/open-pdf-studio/js/i18n/locales/cs/ribbon.json index fb7a743bf..cdb139e13 100644 --- a/open-pdf-studio/js/i18n/locales/cs/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/cs/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Sloučit vše", "flattenAllTooltip": "Sloučit všechny značky do obsahu stránky", "unflattenAll": "Zrušit sloučení", - "unflattenAllTooltip": "Obnovit značky sloučené s povoleným obnovením" + "unflattenAllTooltip": "Obnovit značky sloučené s povoleným obnovením", + "fileAttachment": "Připojit soubor", + "fileAttachmentTitle": "Příloha souboru: klikněte na stránku a vyberte soubor" }, "view": { "pageDisplay": "Zobrazení stránky", diff --git a/open-pdf-studio/js/i18n/locales/da/common.json b/open-pdf-studio/js/i18n/locales/da/common.json index 729b65fff..d3d1fc29f 100644 --- a/open-pdf-studio/js/i18n/locales/da/common.json +++ b/open-pdf-studio/js/i18n/locales/da/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Gå til side", "openExternalFile": "Åbn: {{filename}}", "destination": "Destination: {{name}}", - "internalLink": "Internt link" + "internalLink": "Internt link", + "attachmentOnPage": "Side {{page}}", + "attachmentCreated": "Oprettet: {{date}}", + "attachmentModified": "Ændret: {{date}}", + "openAttachmentRiskTitle": "Åbn vedhæftet fil", + "openAttachmentRiskMessage": "\"{{name}}\" kan køre programmer på denne computer. Åbn den kun, hvis du har tillid til kilden. Åbn alligevel?" }, "failedToInsertPagesFromFile": "Sider fra filen kunne ikke indsættes: {{error}}", "previewPdfRefused": "Denne PDF er en forhåndsvisning fra tegningsimporten og kan være ufuldstændig. Den kan ikke indsættes eller gemmes; importér tegningen igen.", diff --git a/open-pdf-studio/js/i18n/locales/da/properties.json b/open-pdf-studio/js/i18n/locales/da/properties.json index 0c0762d6b..a6380dfbb 100644 --- a/open-pdf-studio/js/i18n/locales/da/properties.json +++ b/open-pdf-studio/js/i18n/locales/da/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Papirclips", + "iconPushPin": "Tegnestift", + "iconGraph": "Diagram", + "iconTag": "Mærkat" }, "stylePresets": { "label": "Linjestil", @@ -311,7 +315,8 @@ "measurePerimeter": "Omkreds", "redaction": "Redigering", "scaleBar": "Skalabjælke", - "parametricSymbol": "Parametrisk symbol" + "parametricSymbol": "Parametrisk symbol", + "fileAttachment": "Vedhæftet fil" }, "leftPanel": { "togglePanel": "Skift panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Lodret åben", "inb-horizontaal-breed": "Vandret åben" } + }, + "attachment": { + "file": "Fil", + "size": "Størrelse", + "open": "Åbn", + "saveAs": "Gem som...", + "noContent": "Indholdet af denne fil er ikke indlejret i PDF'en." } } diff --git a/open-pdf-studio/js/i18n/locales/da/ribbon.json b/open-pdf-studio/js/i18n/locales/da/ribbon.json index 502114881..8682f0a02 100644 --- a/open-pdf-studio/js/i18n/locales/da/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/da/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Fladgør alle", "flattenAllTooltip": "Fladgør alle markeringer til sideindhold", "unflattenAll": "Fortryd fladgøring", - "unflattenAllTooltip": "Gendan markeringer, der blev fladgjort med gendannelse tilladt" + "unflattenAllTooltip": "Gendan markeringer, der blev fladgjort med gendannelse tilladt", + "fileAttachment": "Vedhæft fil", + "fileAttachmentTitle": "Vedhæftet fil: klik på siden og vælg en fil" }, "view": { "pageDisplay": "Sidevisning", diff --git a/open-pdf-studio/js/i18n/locales/de/common.json b/open-pdf-studio/js/i18n/locales/de/common.json index 9f526f6ff..da439e21e 100644 --- a/open-pdf-studio/js/i18n/locales/de/common.json +++ b/open-pdf-studio/js/i18n/locales/de/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Gehe zu Seite", "openExternalFile": "Öffnen: {{filename}}", "destination": "Ziel: {{name}}", - "internalLink": "Interner Link" + "internalLink": "Interner Link", + "attachmentOnPage": "Seite {{page}}", + "attachmentCreated": "Erstellt: {{date}}", + "attachmentModified": "Geändert: {{date}}", + "openAttachmentRiskTitle": "Anhang öffnen", + "openAttachmentRiskMessage": "\"{{name}}\" kann Programme auf diesem Computer ausführen. Öffnen Sie die Datei nur, wenn Sie der Quelle vertrauen. Trotzdem öffnen?" }, "failedToInsertPagesFromFile": "Seiten aus Datei konnten nicht eingefügt werden: {{error}}", "previewPdfRefused": "Diese PDF ist eine Vorschau aus dem Zeichnungsimport und kann unvollständig sein. Sie kann nicht eingefügt oder gespeichert werden; importieren Sie die Zeichnung erneut.", diff --git a/open-pdf-studio/js/i18n/locales/de/properties.json b/open-pdf-studio/js/i18n/locales/de/properties.json index 1eb26e287..c20c99407 100644 --- a/open-pdf-studio/js/i18n/locales/de/properties.json +++ b/open-pdf-studio/js/i18n/locales/de/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Büroklammer", + "iconPushPin": "Reißzwecke", + "iconGraph": "Diagramm", + "iconTag": "Etikett" }, "stylePresets": { "label": "Linienstil", @@ -311,7 +315,8 @@ "measurePerimeter": "Umfang", "redaction": "Schwärzung", "scaleBar": "Maßstabsleiste", - "parametricSymbol": "Parametrisches Symbol" + "parametricSymbol": "Parametrisches Symbol", + "fileAttachment": "Dateianhang" }, "leftPanel": { "togglePanel": "Seitenleiste ein-/ausblenden", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertikal weit", "inb-horizontaal-breed": "Horizontal weit" } + }, + "attachment": { + "file": "Datei", + "size": "Größe", + "open": "Öffnen", + "saveAs": "Speichern unter...", + "noContent": "Der Inhalt dieser Datei ist nicht in die PDF eingebettet." } } diff --git a/open-pdf-studio/js/i18n/locales/de/ribbon.json b/open-pdf-studio/js/i18n/locales/de/ribbon.json index 6d18d3688..95121ff2d 100644 --- a/open-pdf-studio/js/i18n/locales/de/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/de/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Alle reduzieren", "flattenAllTooltip": "Alle Markierungen auf den Seiteninhalt reduzieren", "unflattenAll": "Reduzieren aufheben", - "unflattenAllTooltip": "Markierungen wiederherstellen, die mit erlaubter Wiederherstellung reduziert wurden" + "unflattenAllTooltip": "Markierungen wiederherstellen, die mit erlaubter Wiederherstellung reduziert wurden", + "fileAttachment": "Datei anhängen", + "fileAttachmentTitle": "Dateianhang: auf die Seite klicken und eine Datei wählen" }, "view": { "pageDisplay": "Seitenanzeige", diff --git a/open-pdf-studio/js/i18n/locales/el/common.json b/open-pdf-studio/js/i18n/locales/el/common.json index bf5b84978..06269b3c9 100644 --- a/open-pdf-studio/js/i18n/locales/el/common.json +++ b/open-pdf-studio/js/i18n/locales/el/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Μετάβαση στη σελίδα", "openExternalFile": "Άνοιγμα: {{filename}}", "destination": "Προορισμός: {{name}}", - "internalLink": "Εσωτερικός σύνδεσμος" + "internalLink": "Εσωτερικός σύνδεσμος", + "attachmentOnPage": "Σελίδα {{page}}", + "attachmentCreated": "Δημιουργήθηκε: {{date}}", + "attachmentModified": "Τροποποιήθηκε: {{date}}", + "openAttachmentRiskTitle": "Άνοιγμα συνημμένου", + "openAttachmentRiskMessage": "Το «{{name}}» μπορεί να εκτελέσει προγράμματα σε αυτόν τον υπολογιστή. Ανοίξτε το μόνο αν εμπιστεύεστε την προέλευσή του. Άνοιγμα παρ' όλα αυτά;" }, "failedToInsertPagesFromFile": "Η εισαγωγή σελίδων από αρχείο απέτυχε: {{error}}", "previewPdfRefused": "Αυτό το PDF είναι προεπισκόπηση από την εισαγωγή του σχεδίου και ίσως είναι ελλιπές. Δεν μπορεί να εισαχθεί ή να αποθηκευτεί· εισαγάγετε ξανά το σχέδιο.", diff --git a/open-pdf-studio/js/i18n/locales/el/properties.json b/open-pdf-studio/js/i18n/locales/el/properties.json index 9d1fd871d..b721fadb0 100644 --- a/open-pdf-studio/js/i18n/locales/el/properties.json +++ b/open-pdf-studio/js/i18n/locales/el/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Συνδετήρας", + "iconPushPin": "Πινέζα", + "iconGraph": "Γράφημα", + "iconTag": "Ετικέτα" }, "stylePresets": { "label": "Στυλ γραμμής", @@ -311,7 +315,8 @@ "measurePerimeter": "Περίμετρος", "redaction": "Απαλοιφή", "scaleBar": "Γραμμή κλίμακας", - "parametricSymbol": "Παραμετρικό σύμβολο" + "parametricSymbol": "Παραμετρικό σύμβολο", + "fileAttachment": "Συνημμένο αρχείο" }, "leftPanel": { "togglePanel": "Εναλλαγή πίνακα", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Κατακόρυφη διαγράμμιση αραιή", "inb-horizontaal-breed": "Οριζόντια διαγράμμιση αραιή" } + }, + "attachment": { + "file": "Αρχείο", + "size": "Μέγεθος", + "open": "Άνοιγμα", + "saveAs": "Αποθήκευση ως...", + "noContent": "Το περιεχόμενο αυτού του αρχείου δεν είναι ενσωματωμένο στο PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/el/ribbon.json b/open-pdf-studio/js/i18n/locales/el/ribbon.json index 8af4aa67b..fa5e89c18 100644 --- a/open-pdf-studio/js/i18n/locales/el/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/el/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Ισοπέδωση όλων", "flattenAllTooltip": "Ισοπέδωση όλων των σημάνσεων στο περιεχόμενο της σελίδας", "unflattenAll": "Αναίρεση ισοπέδωσης", - "unflattenAllTooltip": "Επαναφορά σημάνσεων που ισοπεδώθηκαν με επιτρεπόμενη επαναφορά" + "unflattenAllTooltip": "Επαναφορά σημάνσεων που ισοπεδώθηκαν με επιτρεπόμενη επαναφορά", + "fileAttachment": "Επισύναψη αρχείου", + "fileAttachmentTitle": "Συνημμένο αρχείο: κάντε κλικ στη σελίδα και επιλέξτε ένα αρχείο" }, "view": { "pageDisplay": "Εμφάνιση σελίδας", diff --git a/open-pdf-studio/js/i18n/locales/en/common.json b/open-pdf-studio/js/i18n/locales/en/common.json index 0368c5c85..fff3840d8 100644 --- a/open-pdf-studio/js/i18n/locales/en/common.json +++ b/open-pdf-studio/js/i18n/locales/en/common.json @@ -233,7 +233,12 @@ "goToPageLink": "Go to page", "openExternalFile": "Open: {{filename}}", "destination": "Destination: {{name}}", - "internalLink": "Internal Link" + "internalLink": "Internal Link", + "attachmentOnPage": "Page {{page}}", + "attachmentCreated": "Created: {{date}}", + "attachmentModified": "Modified: {{date}}", + "openAttachmentRiskTitle": "Open attachment", + "openAttachmentRiskMessage": "\"{{name}}\" can run programs on this computer. Only open it if you trust the source. Open anyway?" }, "failedToInsertPagesFromFile": "Failed to insert pages from file: {{error}}", "previewPdfRefused": "This PDF is a preview from the drawing import and may be incomplete. It cannot be inserted or saved; import the drawing again.", diff --git a/open-pdf-studio/js/i18n/locales/en/properties.json b/open-pdf-studio/js/i18n/locales/en/properties.json index f31211e34..6d46e37b5 100644 --- a/open-pdf-studio/js/i18n/locales/en/properties.json +++ b/open-pdf-studio/js/i18n/locales/en/properties.json @@ -142,7 +142,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Paperclip", + "iconPushPin": "Push Pin", + "iconGraph": "Graph", + "iconTag": "Tag" }, "stylePresets": { "label": "Line style", @@ -343,7 +347,8 @@ "measurePerimeter": "Perimeter", "redaction": "Redaction", "scaleBar": "Scale Bar", - "parametricSymbol": "Parametric Symbol" + "parametricSymbol": "Parametric Symbol", + "fileAttachment": "File Attachment" }, "leftPanel": { "togglePanel": "Toggle Panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertical wide", "inb-horizontaal-breed": "Horizontal wide" } + }, + "attachment": { + "file": "File", + "size": "Size", + "open": "Open", + "saveAs": "Save As...", + "noContent": "The content of this file is not embedded in the PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/en/ribbon.json b/open-pdf-studio/js/i18n/locales/en/ribbon.json index 7e2f52727..afb5dd5ea 100644 --- a/open-pdf-studio/js/i18n/locales/en/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/en/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Flatten All", "flattenAllTooltip": "Flatten all markups into the page content", "unflattenAll": "Unflatten All", - "unflattenAllTooltip": "Restore markups that were flattened with unflattening allowed" + "unflattenAllTooltip": "Restore markups that were flattened with unflattening allowed", + "fileAttachment": "Attach File", + "fileAttachmentTitle": "File attachment: click on the page and choose a file" }, "view": { "pageDisplay": "Page Display", diff --git a/open-pdf-studio/js/i18n/locales/es/common.json b/open-pdf-studio/js/i18n/locales/es/common.json index ca97c276a..7578051c6 100644 --- a/open-pdf-studio/js/i18n/locales/es/common.json +++ b/open-pdf-studio/js/i18n/locales/es/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Ir a la página", "openExternalFile": "Abrir: {{filename}}", "destination": "Destino: {{name}}", - "internalLink": "Enlace interno" + "internalLink": "Enlace interno", + "attachmentOnPage": "Página {{page}}", + "attachmentCreated": "Creado: {{date}}", + "attachmentModified": "Modificado: {{date}}", + "openAttachmentRiskTitle": "Abrir adjunto", + "openAttachmentRiskMessage": "«{{name}}» puede ejecutar programas en este equipo. Ábralo solo si confía en el origen. ¿Abrir de todos modos?" }, "failedToInsertPagesFromFile": "No se pudieron insertar páginas desde el archivo: {{error}}", "previewPdfRefused": "Este PDF es una vista previa de la importación del dibujo y puede estar incompleto. No se puede insertar ni guardar; vuelva a importar el dibujo.", diff --git a/open-pdf-studio/js/i18n/locales/es/properties.json b/open-pdf-studio/js/i18n/locales/es/properties.json index 1ad18a991..9b1d4d147 100644 --- a/open-pdf-studio/js/i18n/locales/es/properties.json +++ b/open-pdf-studio/js/i18n/locales/es/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Clip", + "iconPushPin": "Chincheta", + "iconGraph": "Gráfico", + "iconTag": "Etiqueta" }, "stylePresets": { "label": "Estilo de línea", @@ -311,7 +315,8 @@ "measurePerimeter": "Perímetro", "redaction": "Redacción", "scaleBar": "Barra de escala", - "parametricSymbol": "Símbolo paramétrico" + "parametricSymbol": "Símbolo paramétrico", + "fileAttachment": "Archivo adjunto" }, "leftPanel": { "togglePanel": "Alternar panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertical ancho", "inb-horizontaal-breed": "Horizontal ancho" } + }, + "attachment": { + "file": "Archivo", + "size": "Tamaño", + "open": "Abrir", + "saveAs": "Guardar como...", + "noContent": "El contenido de este archivo no está incrustado en el PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/es/ribbon.json b/open-pdf-studio/js/i18n/locales/es/ribbon.json index e57b46cde..6033dcd33 100644 --- a/open-pdf-studio/js/i18n/locales/es/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/es/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Aplanar todo", "flattenAllTooltip": "Aplanar todas las marcas en el contenido de la página", "unflattenAll": "Deshacer aplanado", - "unflattenAllTooltip": "Restaurar las marcas aplanadas con la restauración permitida" + "unflattenAllTooltip": "Restaurar las marcas aplanadas con la restauración permitida", + "fileAttachment": "Adjuntar archivo", + "fileAttachmentTitle": "Archivo adjunto: haga clic en la página y elija un archivo" }, "view": { "pageDisplay": "Visualización de página", diff --git a/open-pdf-studio/js/i18n/locales/fa/common.json b/open-pdf-studio/js/i18n/locales/fa/common.json index a4eb6d69f..156245fd1 100644 --- a/open-pdf-studio/js/i18n/locales/fa/common.json +++ b/open-pdf-studio/js/i18n/locales/fa/common.json @@ -228,7 +228,12 @@ "goToPageLink": "رفتن به صفحه", "openExternalFile": "باز کردن: {{filename}}", "destination": "مقصد: {{name}}", - "internalLink": "پیوند داخلی" + "internalLink": "پیوند داخلی", + "attachmentOnPage": "صفحه {{page}}", + "attachmentCreated": "ایجاد: {{date}}", + "attachmentModified": "تغییر: {{date}}", + "openAttachmentRiskTitle": "باز کردن پیوست", + "openAttachmentRiskMessage": "«{{name}}» می‌تواند برنامه‌هایی را روی این رایانه اجرا کند. فقط در صورتی آن را باز کنید که به منبع آن اعتماد دارید. با این حال باز شود؟" }, "failedToInsertPagesFromFile": "درج صفحات از فایل ناموفق بود: {{error}}", "previewPdfRefused": "این PDF پیش‌نمایشی از درون‌ریزی نقشه است و ممکن است ناقص باشد. نمی‌توان آن را درج یا ذخیره کرد؛ نقشه را دوباره درون‌ریزی کنید.", diff --git a/open-pdf-studio/js/i18n/locales/fa/properties.json b/open-pdf-studio/js/i18n/locales/fa/properties.json index 1c25cc35c..5dd65b534 100644 --- a/open-pdf-studio/js/i18n/locales/fa/properties.json +++ b/open-pdf-studio/js/i18n/locales/fa/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "گیره کاغذ", + "iconPushPin": "پونز", + "iconGraph": "نمودار", + "iconTag": "برچسب" }, "stylePresets": { "label": "سبک خط", @@ -311,7 +315,8 @@ "measurePerimeter": "محیط", "redaction": "ویرایش محرمانه", "scaleBar": "نوار مقیاس", - "parametricSymbol": "نماد پارامتریک" + "parametricSymbol": "نماد پارامتریک", + "fileAttachment": "پیوست فایل" }, "leftPanel": { "togglePanel": "تغییر وضعیت پنل", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "عمودی باز", "inb-horizontaal-breed": "افقی باز" } + }, + "attachment": { + "file": "فایل", + "size": "اندازه", + "open": "باز کردن", + "saveAs": "ذخیره به عنوان...", + "noContent": "محتوای این فایل در PDF جاسازی نشده است." } } diff --git a/open-pdf-studio/js/i18n/locales/fa/ribbon.json b/open-pdf-studio/js/i18n/locales/fa/ribbon.json index 1a40f633c..bd6d547ec 100644 --- a/open-pdf-studio/js/i18n/locales/fa/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/fa/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "مسطح‌سازی همه", "flattenAllTooltip": "مسطح‌سازی همهٔ نشانه‌گذاری‌ها در محتوای صفحه", "unflattenAll": "لغو مسطح‌سازی", - "unflattenAllTooltip": "بازگرداندن نشانه‌گذاری‌هایی که با اجازهٔ بازگردانی مسطح شده‌اند" + "unflattenAllTooltip": "بازگرداندن نشانه‌گذاری‌هایی که با اجازهٔ بازگردانی مسطح شده‌اند", + "fileAttachment": "پیوست کردن فایل", + "fileAttachmentTitle": "پیوست فایل: روی صفحه کلیک کنید و فایلی را انتخاب کنید" }, "view": { "pageDisplay": "نمایش صفحه", diff --git a/open-pdf-studio/js/i18n/locales/fi/common.json b/open-pdf-studio/js/i18n/locales/fi/common.json index bf95f4eff..aaf3edf59 100644 --- a/open-pdf-studio/js/i18n/locales/fi/common.json +++ b/open-pdf-studio/js/i18n/locales/fi/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Siirry sivulle", "openExternalFile": "Avaa: {{filename}}", "destination": "Kohde: {{name}}", - "internalLink": "Sisäinen linkki" + "internalLink": "Sisäinen linkki", + "attachmentOnPage": "Sivu {{page}}", + "attachmentCreated": "Luotu: {{date}}", + "attachmentModified": "Muokattu: {{date}}", + "openAttachmentRiskTitle": "Avaa liite", + "openAttachmentRiskMessage": "\"{{name}}\" voi suorittaa ohjelmia tällä tietokoneella. Avaa se vain, jos luotat lähteeseen. Avataanko silti?" }, "failedToInsertPagesFromFile": "Sivujen lisääminen tiedostosta epäonnistui: {{error}}", "previewPdfRefused": "Tämä PDF on piirustuksen tuonnin esikatselu ja voi olla puutteellinen. Sitä ei voi lisätä eikä tallentaa; tuo piirustus uudelleen.", diff --git a/open-pdf-studio/js/i18n/locales/fi/properties.json b/open-pdf-studio/js/i18n/locales/fi/properties.json index 73e1e640a..89432bf53 100644 --- a/open-pdf-studio/js/i18n/locales/fi/properties.json +++ b/open-pdf-studio/js/i18n/locales/fi/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Paperiliitin", + "iconPushPin": "Nasta", + "iconGraph": "Kaavio", + "iconTag": "Tunniste" }, "stylePresets": { "label": "Viivatyyli", @@ -311,7 +315,8 @@ "measurePerimeter": "Piiri", "redaction": "Poisto", "scaleBar": "Mittakaavajana", - "parametricSymbol": "Parametrinen symboli" + "parametricSymbol": "Parametrinen symboli", + "fileAttachment": "Tiedostoliite" }, "leftPanel": { "togglePanel": "Näytä/piilota paneeli", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Pystyviivoitus harva", "inb-horizontaal-breed": "Vaakaviivoitus harva" } + }, + "attachment": { + "file": "Tiedosto", + "size": "Koko", + "open": "Avaa", + "saveAs": "Tallenna nimellä...", + "noContent": "Tämän tiedoston sisältöä ei ole upotettu PDF-tiedostoon." } } diff --git a/open-pdf-studio/js/i18n/locales/fi/ribbon.json b/open-pdf-studio/js/i18n/locales/fi/ribbon.json index ab5fd6ba0..eca23b5df 100644 --- a/open-pdf-studio/js/i18n/locales/fi/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/fi/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Litistä kaikki", "flattenAllTooltip": "Litistä kaikki merkinnät sivun sisällöksi", "unflattenAll": "Kumoa litistys", - "unflattenAllTooltip": "Palauta merkinnät, jotka litistettiin palautus sallittuna" + "unflattenAllTooltip": "Palauta merkinnät, jotka litistettiin palautus sallittuna", + "fileAttachment": "Liitä tiedosto", + "fileAttachmentTitle": "Tiedostoliite: napsauta sivua ja valitse tiedosto" }, "view": { "pageDisplay": "Sivun näyttö", diff --git a/open-pdf-studio/js/i18n/locales/fr/common.json b/open-pdf-studio/js/i18n/locales/fr/common.json index d83147571..483d0a25e 100644 --- a/open-pdf-studio/js/i18n/locales/fr/common.json +++ b/open-pdf-studio/js/i18n/locales/fr/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Aller à la page", "openExternalFile": "Ouvrir : {{filename}}", "destination": "Destination : {{name}}", - "internalLink": "Lien interne" + "internalLink": "Lien interne", + "attachmentOnPage": "Page {{page}}", + "attachmentCreated": "Créé : {{date}}", + "attachmentModified": "Modifié : {{date}}", + "openAttachmentRiskTitle": "Ouvrir la pièce jointe", + "openAttachmentRiskMessage": "« {{name}} » peut exécuter des programmes sur cet ordinateur. Ne l'ouvrez que si vous faites confiance à la source. Ouvrir quand même ?" }, "failedToInsertPagesFromFile": "Impossible d'insérer les pages depuis le fichier : {{error}}", "previewPdfRefused": "Ce PDF est un aperçu issu de l’importation du dessin et peut être incomplet. Il ne peut être ni inséré ni enregistré ; importez de nouveau le dessin.", diff --git a/open-pdf-studio/js/i18n/locales/fr/properties.json b/open-pdf-studio/js/i18n/locales/fr/properties.json index 3d368421a..617b02ef8 100644 --- a/open-pdf-studio/js/i18n/locales/fr/properties.json +++ b/open-pdf-studio/js/i18n/locales/fr/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Trombone", + "iconPushPin": "Punaise", + "iconGraph": "Graphique", + "iconTag": "Étiquette" }, "stylePresets": { "label": "Style de ligne", @@ -311,7 +315,8 @@ "measurePerimeter": "Périmètre", "redaction": "Caviardage", "scaleBar": "Barre d'échelle", - "parametricSymbol": "Symbole paramétrique" + "parametricSymbol": "Symbole paramétrique", + "fileAttachment": "Pièce jointe" }, "leftPanel": { "togglePanel": "Afficher/masquer le panneau", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertical espacé", "inb-horizontaal-breed": "Horizontal espacé" } + }, + "attachment": { + "file": "Fichier", + "size": "Taille", + "open": "Ouvrir", + "saveAs": "Enregistrer sous...", + "noContent": "Le contenu de ce fichier n'est pas incorporé dans le PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/fr/ribbon.json b/open-pdf-studio/js/i18n/locales/fr/ribbon.json index 0feec4cb9..ab434900c 100644 --- a/open-pdf-studio/js/i18n/locales/fr/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/fr/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Tout aplatir", "flattenAllTooltip": "Aplatir toutes les annotations dans le contenu de la page", "unflattenAll": "Annuler l'aplatissement", - "unflattenAllTooltip": "Restaurer les annotations aplaties avec restauration autorisée" + "unflattenAllTooltip": "Restaurer les annotations aplaties avec restauration autorisée", + "fileAttachment": "Joindre un fichier", + "fileAttachmentTitle": "Pièce jointe : cliquez sur la page et choisissez un fichier" }, "view": { "pageDisplay": "Affichage de la page", diff --git a/open-pdf-studio/js/i18n/locales/he/common.json b/open-pdf-studio/js/i18n/locales/he/common.json index 1029f50b2..0cfc312b1 100644 --- a/open-pdf-studio/js/i18n/locales/he/common.json +++ b/open-pdf-studio/js/i18n/locales/he/common.json @@ -228,7 +228,12 @@ "goToPageLink": "עבור לעמוד", "openExternalFile": "פתיחה: {{filename}}", "destination": "יעד: {{name}}", - "internalLink": "קישור פנימי" + "internalLink": "קישור פנימי", + "attachmentOnPage": "עמוד {{page}}", + "attachmentCreated": "נוצר: {{date}}", + "attachmentModified": "שונה: {{date}}", + "openAttachmentRiskTitle": "פתיחת קובץ מצורף", + "openAttachmentRiskMessage": "\"{{name}}\" יכול להפעיל תוכניות במחשב זה. פתחו אותו רק אם אתם סומכים על המקור. לפתוח בכל זאת?" }, "failedToInsertPagesFromFile": "הוספת עמודים מקובץ נכשלה: {{error}}", "previewPdfRefused": "קובץ PDF זה הוא תצוגה מקדימה מייבוא השרטוט וייתכן שאינו שלם. לא ניתן להוסיף או לשמור אותו; ייבא את השרטוט מחדש.", diff --git a/open-pdf-studio/js/i18n/locales/he/properties.json b/open-pdf-studio/js/i18n/locales/he/properties.json index 31e75d88b..f454039e7 100644 --- a/open-pdf-studio/js/i18n/locales/he/properties.json +++ b/open-pdf-studio/js/i18n/locales/he/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "מהדק", + "iconPushPin": "נעץ", + "iconGraph": "תרשים", + "iconTag": "תגית" }, "stylePresets": { "label": "סגנון קו", @@ -311,7 +315,8 @@ "measurePerimeter": "היקף", "redaction": "השחרה", "scaleBar": "סרגל קנה מידה", - "parametricSymbol": "סמל פרמטרי" + "parametricSymbol": "סמל פרמטרי", + "fileAttachment": "קובץ מצורף" }, "leftPanel": { "togglePanel": "החלפת לוח", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "אנכי מרווח", "inb-horizontaal-breed": "אופקי מרווח" } + }, + "attachment": { + "file": "קובץ", + "size": "גודל", + "open": "פתיחה", + "saveAs": "שמירה בשם...", + "noContent": "תוכן הקובץ הזה אינו מוטמע ב-PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/he/ribbon.json b/open-pdf-studio/js/i18n/locales/he/ribbon.json index 919093101..a02300e80 100644 --- a/open-pdf-studio/js/i18n/locales/he/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/he/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "שטח הכל", "flattenAllTooltip": "שיטוח כל הסימונים לתוכן העמוד", "unflattenAll": "בטל שיטוח", - "unflattenAllTooltip": "שחזור סימונים ששוטחו כשהשחזור הותר" + "unflattenAllTooltip": "שחזור סימונים ששוטחו כשהשחזור הותר", + "fileAttachment": "צירוף קובץ", + "fileAttachmentTitle": "קובץ מצורף: לחצו על העמוד ובחרו קובץ" }, "view": { "pageDisplay": "תצוגת עמוד", diff --git a/open-pdf-studio/js/i18n/locales/hi/common.json b/open-pdf-studio/js/i18n/locales/hi/common.json index dae387764..57d697661 100644 --- a/open-pdf-studio/js/i18n/locales/hi/common.json +++ b/open-pdf-studio/js/i18n/locales/hi/common.json @@ -228,7 +228,12 @@ "goToPageLink": "पृष्ठ पर जाएं", "openExternalFile": "खोलें: {{filename}}", "destination": "गंतव्य: {{name}}", - "internalLink": "आंतरिक लिंक" + "internalLink": "आंतरिक लिंक", + "attachmentOnPage": "पृष्ठ {{page}}", + "attachmentCreated": "बनाया गया: {{date}}", + "attachmentModified": "संशोधित: {{date}}", + "openAttachmentRiskTitle": "अनुलग्नक खोलें", + "openAttachmentRiskMessage": "\"{{name}}\" इस कंप्यूटर पर प्रोग्राम चला सकता है। इसे केवल तभी खोलें जब आपको स्रोत पर भरोसा हो। फिर भी खोलें?" }, "failedToInsertPagesFromFile": "फ़ाइल से पृष्ठ डालने में विफल: {{error}}", "previewPdfRefused": "यह PDF ड्रॉइंग आयात का पूर्वावलोकन है और अधूरा हो सकता है। इसे न जोड़ा जा सकता है, न सहेजा जा सकता है; ड्रॉइंग को फिर से आयात करें।", diff --git a/open-pdf-studio/js/i18n/locales/hi/properties.json b/open-pdf-studio/js/i18n/locales/hi/properties.json index 42929a193..39c84225e 100644 --- a/open-pdf-studio/js/i18n/locales/hi/properties.json +++ b/open-pdf-studio/js/i18n/locales/hi/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "पेपरक्लिप", + "iconPushPin": "पुशपिन", + "iconGraph": "ग्राफ़", + "iconTag": "टैग" }, "stylePresets": { "label": "रेखा शैली", @@ -311,7 +315,8 @@ "measurePerimeter": "परिधि", "redaction": "संपादन-रहित", "scaleBar": "स्केल बार", - "parametricSymbol": "पैरामीट्रिक प्रतीक" + "parametricSymbol": "पैरामीट्रिक प्रतीक", + "fileAttachment": "फ़ाइल अनुलग्नक" }, "leftPanel": { "togglePanel": "पैनल टॉगल करें", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "लंबवत विरल", "inb-horizontaal-breed": "क्षैतिज विरल" } + }, + "attachment": { + "file": "फ़ाइल", + "size": "आकार", + "open": "खोलें", + "saveAs": "इस रूप में सहेजें...", + "noContent": "इस फ़ाइल की सामग्री PDF में एम्बेड नहीं है।" } } diff --git a/open-pdf-studio/js/i18n/locales/hi/ribbon.json b/open-pdf-studio/js/i18n/locales/hi/ribbon.json index 74d30924f..8bdd10f5f 100644 --- a/open-pdf-studio/js/i18n/locales/hi/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/hi/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "सभी समतल करें", "flattenAllTooltip": "सभी मार्कअप को पृष्ठ सामग्री में समतल करें", "unflattenAll": "समतल करना पूर्ववत करें", - "unflattenAllTooltip": "पुनर्स्थापना की अनुमति के साथ समतल किए गए मार्कअप पुनर्स्थापित करें" + "unflattenAllTooltip": "पुनर्स्थापना की अनुमति के साथ समतल किए गए मार्कअप पुनर्स्थापित करें", + "fileAttachment": "फ़ाइल संलग्न करें", + "fileAttachmentTitle": "फ़ाइल अनुलग्नक: पृष्ठ पर क्लिक करें और एक फ़ाइल चुनें" }, "view": { "pageDisplay": "पृष्ठ प्रदर्शन", diff --git a/open-pdf-studio/js/i18n/locales/hr/common.json b/open-pdf-studio/js/i18n/locales/hr/common.json index 3c2bde5f8..61f8a489a 100644 --- a/open-pdf-studio/js/i18n/locales/hr/common.json +++ b/open-pdf-studio/js/i18n/locales/hr/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Idi na stranicu", "openExternalFile": "Otvori: {{filename}}", "destination": "Odredište: {{name}}", - "internalLink": "Unutarnja poveznica" + "internalLink": "Unutarnja poveznica", + "attachmentOnPage": "Stranica {{page}}", + "attachmentCreated": "Stvoreno: {{date}}", + "attachmentModified": "Izmijenjeno: {{date}}", + "openAttachmentRiskTitle": "Otvori privitak", + "openAttachmentRiskMessage": "\"{{name}}\" može pokretati programe na ovom računalu. Otvorite ga samo ako vjerujete izvoru. Ipak otvoriti?" }, "failedToInsertPagesFromFile": "Umetanje stranica iz datoteke nije uspjelo: {{error}}", "previewPdfRefused": "Ovaj PDF je pregled iz uvoza crteža i može biti nepotpun. Ne može se umetnuti ni spremiti; ponovno uvezite crtež.", diff --git a/open-pdf-studio/js/i18n/locales/hr/properties.json b/open-pdf-studio/js/i18n/locales/hr/properties.json index 12640f33a..3d00ac27f 100644 --- a/open-pdf-studio/js/i18n/locales/hr/properties.json +++ b/open-pdf-studio/js/i18n/locales/hr/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Spajalica", + "iconPushPin": "Pribadača", + "iconGraph": "Grafikon", + "iconTag": "Oznaka" }, "stylePresets": { "label": "Stil linije", @@ -311,7 +315,8 @@ "measurePerimeter": "Opseg", "redaction": "Redigiranje", "scaleBar": "Grafičko mjerilo", - "parametricSymbol": "Parametarski simbol" + "parametricSymbol": "Parametarski simbol", + "fileAttachment": "Privitak datoteke" }, "leftPanel": { "togglePanel": "Prebaci ploču", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Okomita šrafura rijetka", "inb-horizontaal-breed": "Vodoravna šrafura rijetka" } + }, + "attachment": { + "file": "Datoteka", + "size": "Veličina", + "open": "Otvori", + "saveAs": "Spremi kao...", + "noContent": "Sadržaj ove datoteke nije ugrađen u PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/hr/ribbon.json b/open-pdf-studio/js/i18n/locales/hr/ribbon.json index fa81916f8..fa9d9f076 100644 --- a/open-pdf-studio/js/i18n/locales/hr/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/hr/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Spljošti sve", "flattenAllTooltip": "Spljošti sve oznake u sadržaj stranice", "unflattenAll": "Poništi spljoštenje", - "unflattenAllTooltip": "Vrati oznake spljoštene uz dopušteno vraćanje" + "unflattenAllTooltip": "Vrati oznake spljoštene uz dopušteno vraćanje", + "fileAttachment": "Priloži datoteku", + "fileAttachmentTitle": "Privitak datoteke: kliknite na stranicu i odaberite datoteku" }, "view": { "pageDisplay": "Prikaz stranice", diff --git a/open-pdf-studio/js/i18n/locales/hu/common.json b/open-pdf-studio/js/i18n/locales/hu/common.json index 2c8164c0f..b9a3d9979 100644 --- a/open-pdf-studio/js/i18n/locales/hu/common.json +++ b/open-pdf-studio/js/i18n/locales/hu/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Ugrás az oldalra", "openExternalFile": "Megnyitás: {{filename}}", "destination": "Célpont: {{name}}", - "internalLink": "Belső hivatkozás" + "internalLink": "Belső hivatkozás", + "attachmentOnPage": "{{page}}. oldal", + "attachmentCreated": "Létrehozva: {{date}}", + "attachmentModified": "Módosítva: {{date}}", + "openAttachmentRiskTitle": "Melléklet megnyitása", + "openAttachmentRiskMessage": "A(z) „{{name}}” programokat futtathat ezen a számítógépen. Csak akkor nyissa meg, ha megbízik a forrásban. Megnyitja mégis?" }, "failedToInsertPagesFromFile": "Az oldalak beszúrása fájlból sikertelen: {{error}}", "previewPdfRefused": "Ez a PDF a rajzimportálás előnézete, és hiányos lehet. Nem szúrható be és nem menthető; importálja újra a rajzot.", diff --git a/open-pdf-studio/js/i18n/locales/hu/properties.json b/open-pdf-studio/js/i18n/locales/hu/properties.json index f6d1d9d29..65b8bbd09 100644 --- a/open-pdf-studio/js/i18n/locales/hu/properties.json +++ b/open-pdf-studio/js/i18n/locales/hu/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Gemkapocs", + "iconPushPin": "Rajzszög", + "iconGraph": "Diagram", + "iconTag": "Címke" }, "stylePresets": { "label": "Vonalstílus", @@ -311,7 +315,8 @@ "measurePerimeter": "Kerület", "redaction": "Kitörlés", "scaleBar": "Aránymérték", - "parametricSymbol": "Paraméteres szimbólum" + "parametricSymbol": "Paraméteres szimbólum", + "fileAttachment": "Fájlmelléklet" }, "leftPanel": { "togglePanel": "Panel váltása", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Függőleges sraffozás ritka", "inb-horizontaal-breed": "Vízszintes sraffozás ritka" } + }, + "attachment": { + "file": "Fájl", + "size": "Méret", + "open": "Megnyitás", + "saveAs": "Mentés másként...", + "noContent": "A fájl tartalma nincs beágyazva a PDF-be." } } diff --git a/open-pdf-studio/js/i18n/locales/hu/ribbon.json b/open-pdf-studio/js/i18n/locales/hu/ribbon.json index 7a4b5a3af..d31b2ae52 100644 --- a/open-pdf-studio/js/i18n/locales/hu/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/hu/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Összes lapítása", "flattenAllTooltip": "Az összes jelölés lapítása az oldal tartalmába", "unflattenAll": "Lapítás visszavonása", - "unflattenAllTooltip": "Az engedélyezett visszaállítással lapított jelölések visszaállítása" + "unflattenAllTooltip": "Az engedélyezett visszaállítással lapított jelölések visszaállítása", + "fileAttachment": "Fájl csatolása", + "fileAttachmentTitle": "Fájlmelléklet: kattintson az oldalra, és válasszon fájlt" }, "view": { "pageDisplay": "Oldal megjelenítés", diff --git a/open-pdf-studio/js/i18n/locales/id/common.json b/open-pdf-studio/js/i18n/locales/id/common.json index 40a7536b8..828695bf0 100644 --- a/open-pdf-studio/js/i18n/locales/id/common.json +++ b/open-pdf-studio/js/i18n/locales/id/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Ke Halaman", "openExternalFile": "Buka: {{filename}}", "destination": "Tujuan: {{name}}", - "internalLink": "Tautan Internal" + "internalLink": "Tautan Internal", + "attachmentOnPage": "Halaman {{page}}", + "attachmentCreated": "Dibuat: {{date}}", + "attachmentModified": "Diubah: {{date}}", + "openAttachmentRiskTitle": "Buka lampiran", + "openAttachmentRiskMessage": "\"{{name}}\" dapat menjalankan program di komputer ini. Buka hanya jika Anda memercayai sumbernya. Tetap buka?" }, "failedToInsertPagesFromFile": "Gagal menyisipkan halaman dari berkas: {{error}}", "previewPdfRefused": "PDF ini adalah pratinjau dari impor gambar dan mungkin tidak lengkap. PDF ini tidak dapat disisipkan atau disimpan; impor ulang gambarnya.", diff --git a/open-pdf-studio/js/i18n/locales/id/properties.json b/open-pdf-studio/js/i18n/locales/id/properties.json index fff421de0..69d406004 100644 --- a/open-pdf-studio/js/i18n/locales/id/properties.json +++ b/open-pdf-studio/js/i18n/locales/id/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Klip kertas", + "iconPushPin": "Paku payung", + "iconGraph": "Grafik", + "iconTag": "Tag" }, "stylePresets": { "label": "Gaya garis", @@ -311,7 +315,8 @@ "measurePerimeter": "Keliling", "redaction": "Redaksi", "scaleBar": "Batang Skala", - "parametricSymbol": "Simbol Parametrik" + "parametricSymbol": "Simbol Parametrik", + "fileAttachment": "Lampiran file" }, "leftPanel": { "togglePanel": "Tampilkan/Sembunyikan Panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Tegak renggang", "inb-horizontaal-breed": "Mendatar renggang" } + }, + "attachment": { + "file": "File", + "size": "Ukuran", + "open": "Buka", + "saveAs": "Simpan sebagai...", + "noContent": "Isi file ini tidak disematkan dalam PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/id/ribbon.json b/open-pdf-studio/js/i18n/locales/id/ribbon.json index ef49bf078..ac9b986b2 100644 --- a/open-pdf-studio/js/i18n/locales/id/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/id/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Ratakan semua", "flattenAllTooltip": "Ratakan semua markup ke dalam isi halaman", "unflattenAll": "Batalkan perataan", - "unflattenAllTooltip": "Pulihkan markup yang diratakan dengan pemulihan diizinkan" + "unflattenAllTooltip": "Pulihkan markup yang diratakan dengan pemulihan diizinkan", + "fileAttachment": "Lampirkan file", + "fileAttachmentTitle": "Lampiran file: klik pada halaman dan pilih file" }, "view": { "pageDisplay": "Tampilan Halaman", diff --git a/open-pdf-studio/js/i18n/locales/it/common.json b/open-pdf-studio/js/i18n/locales/it/common.json index 6e29849d9..c71d8fcd9 100644 --- a/open-pdf-studio/js/i18n/locales/it/common.json +++ b/open-pdf-studio/js/i18n/locales/it/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Vai alla pagina", "openExternalFile": "Apri: {{filename}}", "destination": "Destinazione: {{name}}", - "internalLink": "Collegamento interno" + "internalLink": "Collegamento interno", + "attachmentOnPage": "Pagina {{page}}", + "attachmentCreated": "Creato: {{date}}", + "attachmentModified": "Modificato: {{date}}", + "openAttachmentRiskTitle": "Apri allegato", + "openAttachmentRiskMessage": "\"{{name}}\" può eseguire programmi su questo computer. Aprilo solo se ti fidi della fonte. Aprire comunque?" }, "failedToInsertPagesFromFile": "Impossibile inserire pagine dal file: {{error}}", "previewPdfRefused": "Questo PDF è un’anteprima dell’importazione del disegno e può essere incompleto. Non può essere inserito né salvato; importare di nuovo il disegno.", diff --git a/open-pdf-studio/js/i18n/locales/it/properties.json b/open-pdf-studio/js/i18n/locales/it/properties.json index d1a2affec..f4caa324e 100644 --- a/open-pdf-studio/js/i18n/locales/it/properties.json +++ b/open-pdf-studio/js/i18n/locales/it/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Graffetta", + "iconPushPin": "Puntina", + "iconGraph": "Grafico", + "iconTag": "Etichetta" }, "stylePresets": { "label": "Stile di linea", @@ -311,7 +315,8 @@ "measurePerimeter": "Perimetro", "redaction": "Redazione", "scaleBar": "Barra di scala", - "parametricSymbol": "Simbolo parametrico" + "parametricSymbol": "Simbolo parametrico", + "fileAttachment": "Allegato file" }, "leftPanel": { "togglePanel": "Attiva/disattiva pannello", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Verticale rado", "inb-horizontaal-breed": "Orizzontale rado" } + }, + "attachment": { + "file": "File", + "size": "Dimensione", + "open": "Apri", + "saveAs": "Salva con nome...", + "noContent": "Il contenuto di questo file non è incorporato nel PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/it/ribbon.json b/open-pdf-studio/js/i18n/locales/it/ribbon.json index 257a41f8a..679166b1a 100644 --- a/open-pdf-studio/js/i18n/locales/it/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/it/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Appiattisci tutto", "flattenAllTooltip": "Appiattisci tutte le annotazioni nel contenuto della pagina", "unflattenAll": "Annulla appiattimento", - "unflattenAllTooltip": "Ripristina le annotazioni appiattite con il ripristino consentito" + "unflattenAllTooltip": "Ripristina le annotazioni appiattite con il ripristino consentito", + "fileAttachment": "Allega file", + "fileAttachmentTitle": "Allegato: fai clic sulla pagina e scegli un file" }, "view": { "pageDisplay": "Visualizzazione pagina", diff --git a/open-pdf-studio/js/i18n/locales/ja/common.json b/open-pdf-studio/js/i18n/locales/ja/common.json index e1a8b19bc..cefff1f8f 100644 --- a/open-pdf-studio/js/i18n/locales/ja/common.json +++ b/open-pdf-studio/js/i18n/locales/ja/common.json @@ -228,7 +228,12 @@ "goToPageLink": "ページへ移動", "openExternalFile": "開く: {{filename}}", "destination": "移動先:{{name}}", - "internalLink": "内部リンク" + "internalLink": "内部リンク", + "attachmentOnPage": "{{page}} ページ", + "attachmentCreated": "作成日時: {{date}}", + "attachmentModified": "更新日時: {{date}}", + "openAttachmentRiskTitle": "添付ファイルを開く", + "openAttachmentRiskMessage": "「{{name}}」はこのコンピューターでプログラムを実行する可能性があります。提供元を信頼できる場合にのみ開いてください。それでも開きますか?" }, "failedToInsertPagesFromFile": "ファイルからのページ挿入に失敗しました:{{error}}", "previewPdfRefused": "この PDF は図面インポートのプレビューであり、内容が欠けている可能性があります。挿入も保存もできません。図面をもう一度インポートしてください。", diff --git a/open-pdf-studio/js/i18n/locales/ja/properties.json b/open-pdf-studio/js/i18n/locales/ja/properties.json index 8f1f410f7..80d77b903 100644 --- a/open-pdf-studio/js/i18n/locales/ja/properties.json +++ b/open-pdf-studio/js/i18n/locales/ja/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "クリップ", + "iconPushPin": "押しピン", + "iconGraph": "グラフ", + "iconTag": "タグ" }, "stylePresets": { "label": "線スタイル", @@ -311,7 +315,8 @@ "measurePerimeter": "周長", "redaction": "墨消し", "scaleBar": "スケールバー", - "parametricSymbol": "パラメトリックシンボル" + "parametricSymbol": "パラメトリックシンボル", + "fileAttachment": "ファイル添付" }, "leftPanel": { "togglePanel": "パネルの切替", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "縦線 疎", "inb-horizontaal-breed": "横線 疎" } + }, + "attachment": { + "file": "ファイル", + "size": "サイズ", + "open": "開く", + "saveAs": "名前を付けて保存...", + "noContent": "このファイルの内容は PDF に埋め込まれていません。" } } diff --git a/open-pdf-studio/js/i18n/locales/ja/ribbon.json b/open-pdf-studio/js/i18n/locales/ja/ribbon.json index 1bb8213f6..0907537ad 100644 --- a/open-pdf-studio/js/i18n/locales/ja/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ja/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "すべてフラット化", "flattenAllTooltip": "すべてのマークアップをページの内容にフラット化", "unflattenAll": "フラット化を元に戻す", - "unflattenAllTooltip": "復元を許可してフラット化したマークアップを復元" + "unflattenAllTooltip": "復元を許可してフラット化したマークアップを復元", + "fileAttachment": "ファイルを添付", + "fileAttachmentTitle": "ファイル添付: ページをクリックしてファイルを選択" }, "view": { "pageDisplay": "ページ表示", diff --git a/open-pdf-studio/js/i18n/locales/ko/common.json b/open-pdf-studio/js/i18n/locales/ko/common.json index 36268247e..0c4c786da 100644 --- a/open-pdf-studio/js/i18n/locales/ko/common.json +++ b/open-pdf-studio/js/i18n/locales/ko/common.json @@ -228,7 +228,12 @@ "goToPageLink": "페이지로 이동", "openExternalFile": "열기: {{filename}}", "destination": "대상: {{name}}", - "internalLink": "내부 링크" + "internalLink": "내부 링크", + "attachmentOnPage": "{{page}}페이지", + "attachmentCreated": "만든 날짜: {{date}}", + "attachmentModified": "수정한 날짜: {{date}}", + "openAttachmentRiskTitle": "첨부 파일 열기", + "openAttachmentRiskMessage": "\"{{name}}\"은(는) 이 컴퓨터에서 프로그램을 실행할 수 있습니다. 출처를 신뢰하는 경우에만 여세요. 그래도 여시겠습니까?" }, "failedToInsertPagesFromFile": "파일에서 페이지를 삽입하지 못했습니다: {{error}}", "previewPdfRefused": "이 PDF는 도면 가져오기의 미리 보기이며 내용이 빠져 있을 수 있습니다. 삽입하거나 저장할 수 없습니다. 도면을 다시 가져오십시오.", diff --git a/open-pdf-studio/js/i18n/locales/ko/properties.json b/open-pdf-studio/js/i18n/locales/ko/properties.json index e021d97b5..1ebc6bee3 100644 --- a/open-pdf-studio/js/i18n/locales/ko/properties.json +++ b/open-pdf-studio/js/i18n/locales/ko/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "클립", + "iconPushPin": "압정", + "iconGraph": "그래프", + "iconTag": "태그" }, "stylePresets": { "label": "선 스타일", @@ -311,7 +315,8 @@ "measurePerimeter": "둘레", "redaction": "교정", "scaleBar": "축척 막대", - "parametricSymbol": "파라메트릭 기호" + "parametricSymbol": "파라메트릭 기호", + "fileAttachment": "파일 첨부" }, "leftPanel": { "togglePanel": "패널 전환", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "세로선 넓음", "inb-horizontaal-breed": "가로선 넓음" } + }, + "attachment": { + "file": "파일", + "size": "크기", + "open": "열기", + "saveAs": "다른 이름으로 저장...", + "noContent": "이 파일의 내용은 PDF에 포함되어 있지 않습니다." } } diff --git a/open-pdf-studio/js/i18n/locales/ko/ribbon.json b/open-pdf-studio/js/i18n/locales/ko/ribbon.json index bdaabfa75..2ea7a17f5 100644 --- a/open-pdf-studio/js/i18n/locales/ko/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ko/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "모두 병합", "flattenAllTooltip": "모든 마크업을 페이지 내용으로 병합", "unflattenAll": "병합 취소", - "unflattenAllTooltip": "복원을 허용하고 병합한 마크업 복원" + "unflattenAllTooltip": "복원을 허용하고 병합한 마크업 복원", + "fileAttachment": "파일 첨부", + "fileAttachmentTitle": "파일 첨부: 페이지를 클릭하고 파일을 선택하세요" }, "view": { "pageDisplay": "페이지 표시", diff --git a/open-pdf-studio/js/i18n/locales/lo/common.json b/open-pdf-studio/js/i18n/locales/lo/common.json index ded3659d5..b816990ed 100644 --- a/open-pdf-studio/js/i18n/locales/lo/common.json +++ b/open-pdf-studio/js/i18n/locales/lo/common.json @@ -229,7 +229,12 @@ "goToPageLink": "ໄປທີ່ໜ້າ", "openExternalFile": "ເປີດ: {{filename}}", "destination": "ຈຸດໝາຍ: {{name}}", - "internalLink": "ລິ້ງພາຍໃນ" + "internalLink": "ລິ້ງພາຍໃນ", + "attachmentOnPage": "ໜ້າ {{page}}", + "attachmentCreated": "ສ້າງເມື່ອ: {{date}}", + "attachmentModified": "ແກ້ໄຂເມື່ອ: {{date}}", + "openAttachmentRiskTitle": "ເປີດໄຟລ໌ແນບ", + "openAttachmentRiskMessage": "\"{{name}}\" ສາມາດເປີດໃຊ້ໂປຣແກຣມໃນຄອມພິວເຕີນີ້ໄດ້. ເປີດສະເພາະເມື່ອທ່ານເຊື່ອຖືແຫຼ່ງທີ່ມາ. ຍັງຕ້ອງການເປີດບໍ?" }, "failedToInsertPagesFromFile": "ແຊກໜ້າຈາກໄຟລ໌ບໍ່ສຳເລັດ: {{error}}", "previewPdfRefused": "PDF ນີ້ເປັນພຽງຕົວຢ່າງຈາກການນຳເຂົ້າຮູບແຕ້ມ ແລະ ອາດຈະບໍ່ສົມບູນ. ບໍ່ສາມາດແຊກ ຫຼື ບັນທຶກໄດ້; ກະລຸນານຳເຂົ້າຮູບແຕ້ມອີກຄັ້ງ.", diff --git a/open-pdf-studio/js/i18n/locales/lo/properties.json b/open-pdf-studio/js/i18n/locales/lo/properties.json index d99b99a2a..ddeadc766 100644 --- a/open-pdf-studio/js/i18n/locales/lo/properties.json +++ b/open-pdf-studio/js/i18n/locales/lo/properties.json @@ -142,7 +142,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "ຄລິບໜີບເຈ້ຍ", + "iconPushPin": "ເຂັມໝຸດ", + "iconGraph": "ກຣາຟ", + "iconTag": "ປ້າຍ" }, "stylePresets": { "label": "ຮູບແບບເສັ້ນ", @@ -343,7 +347,8 @@ "measurePerimeter": "ລວງຮອບ", "redaction": "ການປິດບັງຂໍ້ມູນ", "scaleBar": "ແຖບມາດຕາສ່ວນ", - "parametricSymbol": "ສັນຍະລັກພາຣາມິເຕີ" + "parametricSymbol": "ສັນຍະລັກພາຣາມິເຕີ", + "fileAttachment": "ໄຟລ໌ແນບ" }, "leftPanel": { "togglePanel": "ເປີດ/ປິດ ແຖບຂ້າງ", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "ເສັ້ນແນວຕັ້ງຫ່າງ", "inb-horizontaal-breed": "ເສັ້ນແນວນອນຫ່າງ" } + }, + "attachment": { + "file": "ໄຟລ໌", + "size": "ຂະໜາດ", + "open": "ເປີດ", + "saveAs": "ບັນທຶກເປັນ...", + "noContent": "ເນື້ອຫາຂອງໄຟລ໌ນີ້ບໍ່ໄດ້ຝັງຢູ່ໃນ PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/lo/ribbon.json b/open-pdf-studio/js/i18n/locales/lo/ribbon.json index c672e97af..65af74621 100644 --- a/open-pdf-studio/js/i18n/locales/lo/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/lo/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "ຮວມທັງໝົດ", "flattenAllTooltip": "ຮວມເຄື່ອງໝາຍທັງໝົດເຂົ້າໃນເນື້ອຫາໜ້າ", "unflattenAll": "ຍົກເລີກການຮວມ", - "unflattenAllTooltip": "ກູ້ຄືນເຄື່ອງໝາຍທີ່ຖືກຮວມໂດຍອະນຸຍາດໃຫ້ກູ້ຄືນ" + "unflattenAllTooltip": "ກູ້ຄືນເຄື່ອງໝາຍທີ່ຖືກຮວມໂດຍອະນຸຍາດໃຫ້ກູ້ຄືນ", + "fileAttachment": "ແນບໄຟລ໌", + "fileAttachmentTitle": "ໄຟລ໌ແນບ: ຄລິກໃສ່ໜ້າ ແລ້ວເລືອກໄຟລ໌" }, "view": { "pageDisplay": "ການສະແດງໜ້າເຈ້ຍ", diff --git a/open-pdf-studio/js/i18n/locales/ms/common.json b/open-pdf-studio/js/i18n/locales/ms/common.json index 4507ef245..752aeb020 100644 --- a/open-pdf-studio/js/i18n/locales/ms/common.json +++ b/open-pdf-studio/js/i18n/locales/ms/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Pergi ke halaman", "openExternalFile": "Buka: {{filename}}", "destination": "Destinasi: {{name}}", - "internalLink": "Pautan Dalaman" + "internalLink": "Pautan Dalaman", + "attachmentOnPage": "Halaman {{page}}", + "attachmentCreated": "Dicipta: {{date}}", + "attachmentModified": "Diubah suai: {{date}}", + "openAttachmentRiskTitle": "Buka lampiran", + "openAttachmentRiskMessage": "\"{{name}}\" boleh menjalankan program pada komputer ini. Buka hanya jika anda mempercayai sumbernya. Buka juga?" }, "failedToInsertPagesFromFile": "Gagal memasukkan halaman daripada fail: {{error}}", "previewPdfRefused": "PDF ini ialah pratonton daripada import lukisan dan mungkin tidak lengkap. Ia tidak boleh disisipkan atau disimpan; import lukisan itu semula.", diff --git a/open-pdf-studio/js/i18n/locales/ms/properties.json b/open-pdf-studio/js/i18n/locales/ms/properties.json index 7e6461b7b..808ab236d 100644 --- a/open-pdf-studio/js/i18n/locales/ms/properties.json +++ b/open-pdf-studio/js/i18n/locales/ms/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Klip kertas", + "iconPushPin": "Pin tekan", + "iconGraph": "Graf", + "iconTag": "Tag" }, "stylePresets": { "label": "Gaya garisan", @@ -311,7 +315,8 @@ "measurePerimeter": "Perimeter", "redaction": "Redaksi", "scaleBar": "Bar Skala", - "parametricSymbol": "Simbol Parametrik" + "parametricSymbol": "Simbol Parametrik", + "fileAttachment": "Lampiran fail" }, "leftPanel": { "togglePanel": "Togol Panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Menegak jarang", "inb-horizontaal-breed": "Mendatar jarang" } + }, + "attachment": { + "file": "Fail", + "size": "Saiz", + "open": "Buka", + "saveAs": "Simpan sebagai...", + "noContent": "Kandungan fail ini tidak dibenamkan dalam PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/ms/ribbon.json b/open-pdf-studio/js/i18n/locales/ms/ribbon.json index 8579bd892..e917fc554 100644 --- a/open-pdf-studio/js/i18n/locales/ms/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ms/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Ratakan semua", "flattenAllTooltip": "Ratakan semua penanda ke dalam kandungan halaman", "unflattenAll": "Buat asal perataan", - "unflattenAllTooltip": "Pulihkan penanda yang diratakan dengan pemulihan dibenarkan" + "unflattenAllTooltip": "Pulihkan penanda yang diratakan dengan pemulihan dibenarkan", + "fileAttachment": "Lampirkan fail", + "fileAttachmentTitle": "Lampiran fail: klik pada halaman dan pilih fail" }, "view": { "pageDisplay": "Paparan Halaman", diff --git a/open-pdf-studio/js/i18n/locales/nb/common.json b/open-pdf-studio/js/i18n/locales/nb/common.json index 879e484cd..dec55260b 100644 --- a/open-pdf-studio/js/i18n/locales/nb/common.json +++ b/open-pdf-studio/js/i18n/locales/nb/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Gå til side", "openExternalFile": "Åpne: {{filename}}", "destination": "Destinasjon: {{name}}", - "internalLink": "Intern lenke" + "internalLink": "Intern lenke", + "attachmentOnPage": "Side {{page}}", + "attachmentCreated": "Opprettet: {{date}}", + "attachmentModified": "Endret: {{date}}", + "openAttachmentRiskTitle": "Åpne vedlegg", + "openAttachmentRiskMessage": "«{{name}}» kan kjøre programmer på denne datamaskinen. Åpne den bare hvis du stoler på kilden. Åpne likevel?" }, "failedToInsertPagesFromFile": "Kunne ikke sette inn sider fra filen: {{error}}", "previewPdfRefused": "Denne PDF-en er en forhåndsvisning fra tegningsimporten og kan være ufullstendig. Den kan ikke settes inn eller lagres; importer tegningen på nytt.", diff --git a/open-pdf-studio/js/i18n/locales/nb/properties.json b/open-pdf-studio/js/i18n/locales/nb/properties.json index cb423739a..dd19591bf 100644 --- a/open-pdf-studio/js/i18n/locales/nb/properties.json +++ b/open-pdf-studio/js/i18n/locales/nb/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Binders", + "iconPushPin": "Tegnestift", + "iconGraph": "Diagram", + "iconTag": "Merkelapp" }, "stylePresets": { "label": "Linjestil", @@ -311,7 +315,8 @@ "measurePerimeter": "Omkrets", "redaction": "Sladding", "scaleBar": "Målestokklinjal", - "parametricSymbol": "Parametrisk symbol" + "parametricSymbol": "Parametrisk symbol", + "fileAttachment": "Filvedlegg" }, "leftPanel": { "togglePanel": "Veksle panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Loddrett åpen", "inb-horizontaal-breed": "Vannrett åpen" } + }, + "attachment": { + "file": "Fil", + "size": "Størrelse", + "open": "Åpne", + "saveAs": "Lagre som...", + "noContent": "Innholdet i denne filen er ikke innebygd i PDF-en." } } diff --git a/open-pdf-studio/js/i18n/locales/nb/ribbon.json b/open-pdf-studio/js/i18n/locales/nb/ribbon.json index 153d00ac1..01a4c4546 100644 --- a/open-pdf-studio/js/i18n/locales/nb/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/nb/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Flat ut alle", "flattenAllTooltip": "Flat ut alle markeringer til sideinnhold", "unflattenAll": "Angre utflating", - "unflattenAllTooltip": "Gjenopprett markeringer som ble flatet ut med gjenoppretting tillatt" + "unflattenAllTooltip": "Gjenopprett markeringer som ble flatet ut med gjenoppretting tillatt", + "fileAttachment": "Legg ved fil", + "fileAttachmentTitle": "Filvedlegg: klikk på siden og velg en fil" }, "view": { "pageDisplay": "Sidevisning", diff --git a/open-pdf-studio/js/i18n/locales/nl/common.json b/open-pdf-studio/js/i18n/locales/nl/common.json index 005a568fa..178d05b58 100644 --- a/open-pdf-studio/js/i18n/locales/nl/common.json +++ b/open-pdf-studio/js/i18n/locales/nl/common.json @@ -195,7 +195,12 @@ "goToPageLink": "Ga naar pagina", "openExternalFile": "Openen: {{filename}}", "destination": "Bestemming: {{name}}", - "internalLink": "Interne koppeling" + "internalLink": "Interne koppeling", + "attachmentOnPage": "Pagina {{page}}", + "attachmentCreated": "Gemaakt: {{date}}", + "attachmentModified": "Gewijzigd: {{date}}", + "openAttachmentRiskTitle": "Bijlage openen", + "openAttachmentRiskMessage": "\"{{name}}\" kan programma's op deze computer uitvoeren. Open het alleen als u de bron vertrouwt. Toch openen?" }, "collapse": "Inklappen", "mixed": "Gemengd", diff --git a/open-pdf-studio/js/i18n/locales/nl/properties.json b/open-pdf-studio/js/i18n/locales/nl/properties.json index 8299b7078..ad8d3d807 100644 --- a/open-pdf-studio/js/i18n/locales/nl/properties.json +++ b/open-pdf-studio/js/i18n/locales/nl/properties.json @@ -142,7 +142,11 @@ "boxShape": "Vorm", "boxShapeRect": "Rechthoek (scherpe hoeken)", "boxShapeRounded": "Rechthoek (afgeronde hoeken)", - "boxShapeEllipse": "Ellips / cirkel" + "boxShapeEllipse": "Ellips / cirkel", + "iconPaperclip": "Paperclip", + "iconPushPin": "Punaise", + "iconGraph": "Grafiek", + "iconTag": "Label" }, "stylePresets": { "label": "Lijnstijl", @@ -291,7 +295,8 @@ "measurePerimeter": "Omtrek", "redaction": "Redactie", "scaleBar": "Schaalbalk", - "parametricSymbol": "Parametrisch symbool" + "parametricSymbol": "Parametrisch symbool", + "fileAttachment": "Bestandsbijlage" }, "leftPanel": { "togglePanel": "Paneel in-/uitschakelen", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Verticaal breed", "inb-horizontaal-breed": "Horizontaal breed" } + }, + "attachment": { + "file": "Bestand", + "size": "Grootte", + "open": "Openen", + "saveAs": "Opslaan als...", + "noContent": "De inhoud van dit bestand is niet in de PDF ingesloten." } } diff --git a/open-pdf-studio/js/i18n/locales/nl/ribbon.json b/open-pdf-studio/js/i18n/locales/nl/ribbon.json index e9f7ff38d..ecc7a771f 100644 --- a/open-pdf-studio/js/i18n/locales/nl/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/nl/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Alles afvlakken", "flattenAllTooltip": "Alle markeringen afvlakken tot pagina-inhoud", "unflattenAll": "Afvlakken ongedaan", - "unflattenAllTooltip": "Markeringen terughalen die zijn afgevlakt met terughalen toegestaan" + "unflattenAllTooltip": "Markeringen terughalen die zijn afgevlakt met terughalen toegestaan", + "fileAttachment": "Bestand bijvoegen", + "fileAttachmentTitle": "Bestandsbijlage: klik op de pagina en kies een bestand" }, "view": { "pageDisplay": "Paginaweergave", diff --git a/open-pdf-studio/js/i18n/locales/pl/common.json b/open-pdf-studio/js/i18n/locales/pl/common.json index bf941312a..2317c4686 100644 --- a/open-pdf-studio/js/i18n/locales/pl/common.json +++ b/open-pdf-studio/js/i18n/locales/pl/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Przejdź do strony", "openExternalFile": "Otwórz: {{filename}}", "destination": "Miejsce docelowe: {{name}}", - "internalLink": "Łącze wewnętrzne" + "internalLink": "Łącze wewnętrzne", + "attachmentOnPage": "Strona {{page}}", + "attachmentCreated": "Utworzono: {{date}}", + "attachmentModified": "Zmodyfikowano: {{date}}", + "openAttachmentRiskTitle": "Otwórz załącznik", + "openAttachmentRiskMessage": "„{{name}}” może uruchamiać programy na tym komputerze. Otwieraj go tylko, jeśli ufasz źródłu. Otworzyć mimo to?" }, "failedToInsertPagesFromFile": "Nie udało się wstawić stron z pliku: {{error}}", "previewPdfRefused": "Ten PDF jest podglądem z importu rysunku i może być niekompletny. Nie można go wstawić ani zapisać; zaimportuj rysunek ponownie.", diff --git a/open-pdf-studio/js/i18n/locales/pl/properties.json b/open-pdf-studio/js/i18n/locales/pl/properties.json index b526185be..3057fc212 100644 --- a/open-pdf-studio/js/i18n/locales/pl/properties.json +++ b/open-pdf-studio/js/i18n/locales/pl/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Spinacz", + "iconPushPin": "Pinezka", + "iconGraph": "Wykres", + "iconTag": "Etykieta" }, "stylePresets": { "label": "Styl linii", @@ -311,7 +315,8 @@ "measurePerimeter": "Obwód", "redaction": "Redakcja", "scaleBar": "Podziałka liniowa", - "parametricSymbol": "Symbol parametryczny" + "parametricSymbol": "Symbol parametryczny", + "fileAttachment": "Załącznik pliku" }, "leftPanel": { "togglePanel": "Przełącz Panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Kreskowanie pionowe rzadkie", "inb-horizontaal-breed": "Kreskowanie poziome rzadkie" } + }, + "attachment": { + "file": "Plik", + "size": "Rozmiar", + "open": "Otwórz", + "saveAs": "Zapisz jako...", + "noContent": "Zawartość tego pliku nie jest osadzona w pliku PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/pl/ribbon.json b/open-pdf-studio/js/i18n/locales/pl/ribbon.json index 5ab8eea42..2a5eb18dd 100644 --- a/open-pdf-studio/js/i18n/locales/pl/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/pl/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Spłaszcz wszystko", "flattenAllTooltip": "Spłaszcz wszystkie adnotacje do zawartości strony", "unflattenAll": "Cofnij spłaszczenie", - "unflattenAllTooltip": "Przywróć adnotacje spłaszczone z dozwolonym przywracaniem" + "unflattenAllTooltip": "Przywróć adnotacje spłaszczone z dozwolonym przywracaniem", + "fileAttachment": "Dołącz plik", + "fileAttachmentTitle": "Załącznik pliku: kliknij stronę i wybierz plik" }, "view": { "pageDisplay": "Wyświetlanie strony", diff --git a/open-pdf-studio/js/i18n/locales/pt/common.json b/open-pdf-studio/js/i18n/locales/pt/common.json index 8e0758115..93a6cda34 100644 --- a/open-pdf-studio/js/i18n/locales/pt/common.json +++ b/open-pdf-studio/js/i18n/locales/pt/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Ir para a página", "openExternalFile": "Abrir: {{filename}}", "destination": "Destino: {{name}}", - "internalLink": "Link interno" + "internalLink": "Link interno", + "attachmentOnPage": "Página {{page}}", + "attachmentCreated": "Criado: {{date}}", + "attachmentModified": "Modificado: {{date}}", + "openAttachmentRiskTitle": "Abrir anexo", + "openAttachmentRiskMessage": "\"{{name}}\" pode executar programas neste computador. Abra-o apenas se confiar na origem. Abrir mesmo assim?" }, "failedToInsertPagesFromFile": "Falha ao inserir páginas do arquivo: {{error}}", "previewPdfRefused": "Este PDF é uma pré-visualização da importação do desenho e pode estar incompleto. Não pode ser inserido nem guardado; importe o desenho novamente.", diff --git a/open-pdf-studio/js/i18n/locales/pt/properties.json b/open-pdf-studio/js/i18n/locales/pt/properties.json index 6d710382d..a2c5ab837 100644 --- a/open-pdf-studio/js/i18n/locales/pt/properties.json +++ b/open-pdf-studio/js/i18n/locales/pt/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Clipe", + "iconPushPin": "Alfinete", + "iconGraph": "Gráfico", + "iconTag": "Etiqueta" }, "stylePresets": { "label": "Estilo de linha", @@ -311,7 +315,8 @@ "measurePerimeter": "Perímetro", "redaction": "Redação", "scaleBar": "Barra de escala", - "parametricSymbol": "Símbolo paramétrico" + "parametricSymbol": "Símbolo paramétrico", + "fileAttachment": "Anexo de arquivo" }, "leftPanel": { "togglePanel": "Alternar Painel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertical largo", "inb-horizontaal-breed": "Horizontal largo" } + }, + "attachment": { + "file": "Arquivo", + "size": "Tamanho", + "open": "Abrir", + "saveAs": "Salvar como...", + "noContent": "O conteúdo deste arquivo não está incorporado no PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/pt/ribbon.json b/open-pdf-studio/js/i18n/locales/pt/ribbon.json index dcee35460..e41cd44ac 100644 --- a/open-pdf-studio/js/i18n/locales/pt/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/pt/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Achatar tudo", "flattenAllTooltip": "Achatar todas as marcações no conteúdo da página", "unflattenAll": "Desfazer achatamento", - "unflattenAllTooltip": "Restaurar as marcações achatadas com restauração permitida" + "unflattenAllTooltip": "Restaurar as marcações achatadas com restauração permitida", + "fileAttachment": "Anexar arquivo", + "fileAttachmentTitle": "Anexo de arquivo: clique na página e escolha um arquivo" }, "view": { "pageDisplay": "Exibição de página", diff --git a/open-pdf-studio/js/i18n/locales/ro/common.json b/open-pdf-studio/js/i18n/locales/ro/common.json index 67eeab06a..25350b525 100644 --- a/open-pdf-studio/js/i18n/locales/ro/common.json +++ b/open-pdf-studio/js/i18n/locales/ro/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Mergi la pagina", "openExternalFile": "Deschidere: {{filename}}", "destination": "Destinatie: {{name}}", - "internalLink": "Link intern" + "internalLink": "Link intern", + "attachmentOnPage": "Pagina {{page}}", + "attachmentCreated": "Creat: {{date}}", + "attachmentModified": "Modificat: {{date}}", + "openAttachmentRiskTitle": "Deschide atașamentul", + "openAttachmentRiskMessage": "„{{name}}” poate rula programe pe acest computer. Deschideți-l doar dacă aveți încredere în sursă. Deschideți totuși?" }, "failedToInsertPagesFromFile": "Paginile din fișier nu au putut fi inserate: {{error}}", "previewPdfRefused": "Acest PDF este o previzualizare din importul desenului și poate fi incomplet. Nu poate fi inserat sau salvat; importați din nou desenul.", diff --git a/open-pdf-studio/js/i18n/locales/ro/properties.json b/open-pdf-studio/js/i18n/locales/ro/properties.json index 260ab062c..27e680b5d 100644 --- a/open-pdf-studio/js/i18n/locales/ro/properties.json +++ b/open-pdf-studio/js/i18n/locales/ro/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Agrafă", + "iconPushPin": "Piuneză", + "iconGraph": "Grafic", + "iconTag": "Etichetă" }, "stylePresets": { "label": "Stil de linie", @@ -311,7 +315,8 @@ "measurePerimeter": "Perimetru", "redaction": "Redactare", "scaleBar": "Bară de scară", - "parametricSymbol": "Simbol parametric" + "parametricSymbol": "Simbol parametric", + "fileAttachment": "Atașament fișier" }, "leftPanel": { "togglePanel": "Comutare panou", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Vertical larg", "inb-horizontaal-breed": "Orizontal larg" } + }, + "attachment": { + "file": "Fișier", + "size": "Dimensiune", + "open": "Deschide", + "saveAs": "Salvare ca...", + "noContent": "Conținutul acestui fișier nu este încorporat în PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/ro/ribbon.json b/open-pdf-studio/js/i18n/locales/ro/ribbon.json index 9949190cd..0625bd27b 100644 --- a/open-pdf-studio/js/i18n/locales/ro/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ro/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Aplatizare totală", "flattenAllTooltip": "Aplatizează toate marcajele în conținutul paginii", "unflattenAll": "Anulare aplatizare", - "unflattenAllTooltip": "Restaurează marcajele aplatizate cu restaurarea permisă" + "unflattenAllTooltip": "Restaurează marcajele aplatizate cu restaurarea permisă", + "fileAttachment": "Atașează fișier", + "fileAttachmentTitle": "Atașament: faceți clic pe pagină și alegeți un fișier" }, "view": { "pageDisplay": "Afisare pagina", diff --git a/open-pdf-studio/js/i18n/locales/ru/common.json b/open-pdf-studio/js/i18n/locales/ru/common.json index 89ea9ced8..42760f975 100644 --- a/open-pdf-studio/js/i18n/locales/ru/common.json +++ b/open-pdf-studio/js/i18n/locales/ru/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Перейти на страницу", "openExternalFile": "Открыть: {{filename}}", "destination": "Назначение: {{name}}", - "internalLink": "Внутренняя ссылка" + "internalLink": "Внутренняя ссылка", + "attachmentOnPage": "Страница {{page}}", + "attachmentCreated": "Создан: {{date}}", + "attachmentModified": "Изменён: {{date}}", + "openAttachmentRiskTitle": "Открыть вложение", + "openAttachmentRiskMessage": "«{{name}}» может запускать программы на этом компьютере. Открывайте его, только если доверяете источнику. Всё равно открыть?" }, "failedToInsertPagesFromFile": "Не удалось вставить страницы из файла: {{error}}", "previewPdfRefused": "Этот PDF — предварительный просмотр из импорта чертежа и может быть неполным. Его нельзя вставить или сохранить; импортируйте чертёж заново.", diff --git a/open-pdf-studio/js/i18n/locales/ru/properties.json b/open-pdf-studio/js/i18n/locales/ru/properties.json index 85e26ee01..a00839c38 100644 --- a/open-pdf-studio/js/i18n/locales/ru/properties.json +++ b/open-pdf-studio/js/i18n/locales/ru/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Скрепка", + "iconPushPin": "Кнопка", + "iconGraph": "Диаграмма", + "iconTag": "Ярлык" }, "stylePresets": { "label": "Стиль линии", @@ -311,7 +315,8 @@ "measurePerimeter": "Периметр", "redaction": "Затирание", "scaleBar": "Масштабная линейка", - "parametricSymbol": "Параметрический символ" + "parametricSymbol": "Параметрический символ", + "fileAttachment": "Вложенный файл" }, "leftPanel": { "togglePanel": "Переключить панель", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Вертикальная штриховка редкая", "inb-horizontaal-breed": "Горизонтальная штриховка редкая" } + }, + "attachment": { + "file": "Файл", + "size": "Размер", + "open": "Открыть", + "saveAs": "Сохранить как...", + "noContent": "Содержимое этого файла не встроено в PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/ru/ribbon.json b/open-pdf-studio/js/i18n/locales/ru/ribbon.json index 528e80e76..401322075 100644 --- a/open-pdf-studio/js/i18n/locales/ru/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ru/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Сгладить все", "flattenAllTooltip": "Сгладить все пометки в содержимое страницы", "unflattenAll": "Отменить сглаживание", - "unflattenAllTooltip": "Восстановить пометки, сглаженные с разрешённым восстановлением" + "unflattenAllTooltip": "Восстановить пометки, сглаженные с разрешённым восстановлением", + "fileAttachment": "Прикрепить файл", + "fileAttachmentTitle": "Вложенный файл: щёлкните на странице и выберите файл" }, "view": { "pageDisplay": "Отображение страницы", diff --git a/open-pdf-studio/js/i18n/locales/sk/common.json b/open-pdf-studio/js/i18n/locales/sk/common.json index 41d33e096..2cbd1e344 100644 --- a/open-pdf-studio/js/i18n/locales/sk/common.json +++ b/open-pdf-studio/js/i18n/locales/sk/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Prejsť na stranu", "openExternalFile": "Otvoriť: {{filename}}", "destination": "Cieľ: {{name}}", - "internalLink": "Interný odkaz" + "internalLink": "Interný odkaz", + "attachmentOnPage": "Strana {{page}}", + "attachmentCreated": "Vytvorené: {{date}}", + "attachmentModified": "Zmenené: {{date}}", + "openAttachmentRiskTitle": "Otvoriť prílohu", + "openAttachmentRiskMessage": "„{{name}}“ môže na tomto počítači spúšťať programy. Otvorte ju, len ak dôverujete zdroju. Napriek tomu otvoriť?" }, "failedToInsertPagesFromFile": "Vloženie strán zo súboru zlyhalo: {{error}}", "previewPdfRefused": "Toto PDF je náhľad z importu výkresu a môže byť neúplné. Nedá sa vložiť ani uložiť; importujte výkres znova.", diff --git a/open-pdf-studio/js/i18n/locales/sk/properties.json b/open-pdf-studio/js/i18n/locales/sk/properties.json index fb6b2d19b..44f4548e0 100644 --- a/open-pdf-studio/js/i18n/locales/sk/properties.json +++ b/open-pdf-studio/js/i18n/locales/sk/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Kancelárska spinka", + "iconPushPin": "Pripináčik", + "iconGraph": "Graf", + "iconTag": "Štítok" }, "stylePresets": { "label": "Štýl čiary", @@ -311,7 +315,8 @@ "measurePerimeter": "Obvod", "redaction": "Redigovanie", "scaleBar": "Grafická mierka", - "parametricSymbol": "Parametrický symbol" + "parametricSymbol": "Parametrický symbol", + "fileAttachment": "Príloha súboru" }, "leftPanel": { "togglePanel": "Prepnúť panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Zvislá šrafa riedka", "inb-horizontaal-breed": "Vodorovná šrafa riedka" } + }, + "attachment": { + "file": "Súbor", + "size": "Veľkosť", + "open": "Otvoriť", + "saveAs": "Uložiť ako...", + "noContent": "Obsah tohto súboru nie je vložený do PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/sk/ribbon.json b/open-pdf-studio/js/i18n/locales/sk/ribbon.json index 7a136d293..e229cd07e 100644 --- a/open-pdf-studio/js/i18n/locales/sk/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/sk/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Sploštiť všetko", "flattenAllTooltip": "Sploštiť všetky označenia do obsahu strany", "unflattenAll": "Zrušiť sploštenie", - "unflattenAllTooltip": "Obnoviť označenia sploštené s povoleným obnovením" + "unflattenAllTooltip": "Obnoviť označenia sploštené s povoleným obnovením", + "fileAttachment": "Pripojiť súbor", + "fileAttachmentTitle": "Príloha súboru: kliknite na stranu a vyberte súbor" }, "view": { "pageDisplay": "Zobrazenie strany", diff --git a/open-pdf-studio/js/i18n/locales/sr/common.json b/open-pdf-studio/js/i18n/locales/sr/common.json index 94363d1a0..c809567e3 100644 --- a/open-pdf-studio/js/i18n/locales/sr/common.json +++ b/open-pdf-studio/js/i18n/locales/sr/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Иди на страницу", "openExternalFile": "Отвори: {{filename}}", "destination": "Одредиште: {{name}}", - "internalLink": "Унутрашњи линк" + "internalLink": "Унутрашњи линк", + "attachmentOnPage": "Страница {{page}}", + "attachmentCreated": "Направљено: {{date}}", + "attachmentModified": "Измењено: {{date}}", + "openAttachmentRiskTitle": "Отвори прилог", + "openAttachmentRiskMessage": "„{{name}}” може да покреће програме на овом рачунару. Отворите га само ако верујете извору. Ипак отворити?" }, "failedToInsertPagesFromFile": "Уметање страница из датотеке није успело: {{error}}", "previewPdfRefused": "Овај PDF је преглед из увоза цртежа и може бити непотпун. Не може се уметнути ни сачувати; поново увезите цртеж.", diff --git a/open-pdf-studio/js/i18n/locales/sr/properties.json b/open-pdf-studio/js/i18n/locales/sr/properties.json index aad5a41ed..08be35a21 100644 --- a/open-pdf-studio/js/i18n/locales/sr/properties.json +++ b/open-pdf-studio/js/i18n/locales/sr/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Спајалица", + "iconPushPin": "Чиода", + "iconGraph": "Графикон", + "iconTag": "Ознака" }, "stylePresets": { "label": "Стил линије", @@ -311,7 +315,8 @@ "measurePerimeter": "Обим", "redaction": "Редакција", "scaleBar": "Графичка размера", - "parametricSymbol": "Параметарски симбол" + "parametricSymbol": "Параметарски симбол", + "fileAttachment": "Прилог датотеке" }, "leftPanel": { "togglePanel": "Прикажи/сакриј панел", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Усправна шрафура ретка", "inb-horizontaal-breed": "Водоравна шрафура ретка" } + }, + "attachment": { + "file": "Датотека", + "size": "Величина", + "open": "Отвори", + "saveAs": "Сачувај као...", + "noContent": "Садржај ове датотеке није уграђен у PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/sr/ribbon.json b/open-pdf-studio/js/i18n/locales/sr/ribbon.json index a508810b3..e1e879a45 100644 --- a/open-pdf-studio/js/i18n/locales/sr/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/sr/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Сравни све", "flattenAllTooltip": "Сравни све ознаке у садржај странице", "unflattenAll": "Поништи сравњивање", - "unflattenAllTooltip": "Врати ознаке сравњене уз дозвољено враћање" + "unflattenAllTooltip": "Врати ознаке сравњене уз дозвољено враћање", + "fileAttachment": "Приложи датотеку", + "fileAttachmentTitle": "Прилог датотеке: кликните на страницу и изаберите датотеку" }, "view": { "pageDisplay": "Приказ странице", diff --git a/open-pdf-studio/js/i18n/locales/sv/common.json b/open-pdf-studio/js/i18n/locales/sv/common.json index 14b132372..bf7747bec 100644 --- a/open-pdf-studio/js/i18n/locales/sv/common.json +++ b/open-pdf-studio/js/i18n/locales/sv/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Gå till sida", "openExternalFile": "Öppna: {{filename}}", "destination": "Destination: {{name}}", - "internalLink": "Intern länk" + "internalLink": "Intern länk", + "attachmentOnPage": "Sida {{page}}", + "attachmentCreated": "Skapad: {{date}}", + "attachmentModified": "Ändrad: {{date}}", + "openAttachmentRiskTitle": "Öppna bilaga", + "openAttachmentRiskMessage": "\"{{name}}\" kan köra program på den här datorn. Öppna den bara om du litar på källan. Öppna ändå?" }, "failedToInsertPagesFromFile": "Det gick inte att infoga sidor från filen: {{error}}", "previewPdfRefused": "Den här PDF-filen är en förhandsvisning från ritningsimporten och kan vara ofullständig. Den kan inte infogas eller sparas; importera ritningen igen.", diff --git a/open-pdf-studio/js/i18n/locales/sv/properties.json b/open-pdf-studio/js/i18n/locales/sv/properties.json index 812e02733..92796d053 100644 --- a/open-pdf-studio/js/i18n/locales/sv/properties.json +++ b/open-pdf-studio/js/i18n/locales/sv/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Gem", + "iconPushPin": "Häftstift", + "iconGraph": "Diagram", + "iconTag": "Etikett" }, "stylePresets": { "label": "Linjestil", @@ -311,7 +315,8 @@ "measurePerimeter": "Omkrets", "redaction": "Maskering", "scaleBar": "Skalstock", - "parametricSymbol": "Parametrisk symbol" + "parametricSymbol": "Parametrisk symbol", + "fileAttachment": "Filbilaga" }, "leftPanel": { "togglePanel": "Växla panel", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Lodrät gles", "inb-horizontaal-breed": "Vågrät gles" } + }, + "attachment": { + "file": "Fil", + "size": "Storlek", + "open": "Öppna", + "saveAs": "Spara som...", + "noContent": "Innehållet i den här filen är inte inbäddat i PDF-filen." } } diff --git a/open-pdf-studio/js/i18n/locales/sv/ribbon.json b/open-pdf-studio/js/i18n/locales/sv/ribbon.json index 796157f92..67c8ee0db 100644 --- a/open-pdf-studio/js/i18n/locales/sv/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/sv/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Platta till alla", "flattenAllTooltip": "Platta till alla markeringar till sidinnehåll", "unflattenAll": "Ångra tillplattning", - "unflattenAllTooltip": "Återställ markeringar som plattades till med återställning tillåten" + "unflattenAllTooltip": "Återställ markeringar som plattades till med återställning tillåten", + "fileAttachment": "Bifoga fil", + "fileAttachmentTitle": "Filbilaga: klicka på sidan och välj en fil" }, "view": { "pageDisplay": "Sidvisning", diff --git a/open-pdf-studio/js/i18n/locales/sw/common.json b/open-pdf-studio/js/i18n/locales/sw/common.json index 50ea72968..5446963f4 100644 --- a/open-pdf-studio/js/i18n/locales/sw/common.json +++ b/open-pdf-studio/js/i18n/locales/sw/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Nenda ukurasa", "openExternalFile": "Fungua: {{filename}}", "destination": "Marudio: {{name}}", - "internalLink": "Kiungo cha ndani" + "internalLink": "Kiungo cha ndani", + "attachmentOnPage": "Ukurasa {{page}}", + "attachmentCreated": "Imeundwa: {{date}}", + "attachmentModified": "Imebadilishwa: {{date}}", + "openAttachmentRiskTitle": "Fungua kiambatisho", + "openAttachmentRiskMessage": "\"{{name}}\" inaweza kuendesha programu kwenye kompyuta hii. Ifungue tu ikiwa unaamini chanzo chake. Ifungue hata hivyo?" }, "failedToInsertPagesFromFile": "Imeshindwa kuingiza kurasa kutoka faili: {{error}}", "previewPdfRefused": "PDF hii ni hakikisho kutoka kwenye uingizaji wa mchoro na huenda haijakamilika. Haiwezi kuingizwa wala kuhifadhiwa; ingiza mchoro tena.", diff --git a/open-pdf-studio/js/i18n/locales/sw/properties.json b/open-pdf-studio/js/i18n/locales/sw/properties.json index ea2f34355..64db71b2d 100644 --- a/open-pdf-studio/js/i18n/locales/sw/properties.json +++ b/open-pdf-studio/js/i18n/locales/sw/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Kibanio cha karatasi", + "iconPushPin": "Pini", + "iconGraph": "Grafu", + "iconTag": "Lebo" }, "stylePresets": { "label": "Mtindo wa mstari", @@ -311,7 +315,8 @@ "measurePerimeter": "Mzunguko", "redaction": "Ufutaji", "scaleBar": "Upau wa Kipimo", - "parametricSymbol": "Ishara ya Vigezo" + "parametricSymbol": "Ishara ya Vigezo", + "fileAttachment": "Kiambatisho cha faili" }, "leftPanel": { "togglePanel": "Badilisha Paneli", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Mistari wima ya mbali", "inb-horizontaal-breed": "Mistari mlalo ya mbali" } + }, + "attachment": { + "file": "Faili", + "size": "Ukubwa", + "open": "Fungua", + "saveAs": "Hifadhi kama...", + "noContent": "Maudhui ya faili hii hayajapachikwa kwenye PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/sw/ribbon.json b/open-pdf-studio/js/i18n/locales/sw/ribbon.json index 7be7ec6f4..61b28adb0 100644 --- a/open-pdf-studio/js/i18n/locales/sw/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/sw/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Sawazisha zote", "flattenAllTooltip": "Sawazisha alama zote kwenye maudhui ya ukurasa", "unflattenAll": "Tendua usawazishaji", - "unflattenAllTooltip": "Rejesha alama zilizosawazishwa huku urejeshaji ukiruhusiwa" + "unflattenAllTooltip": "Rejesha alama zilizosawazishwa huku urejeshaji ukiruhusiwa", + "fileAttachment": "Ambatisha faili", + "fileAttachmentTitle": "Kiambatisho cha faili: bofya kwenye ukurasa na uchague faili" }, "view": { "pageDisplay": "Onyesho la Ukurasa", diff --git a/open-pdf-studio/js/i18n/locales/ta/common.json b/open-pdf-studio/js/i18n/locales/ta/common.json index 5ceb4147e..6e6abd372 100644 --- a/open-pdf-studio/js/i18n/locales/ta/common.json +++ b/open-pdf-studio/js/i18n/locales/ta/common.json @@ -228,7 +228,12 @@ "goToPageLink": "பக்கத்திற்குச் செல்", "openExternalFile": "திற: {{filename}}", "destination": "இலக்கு: {{name}}", - "internalLink": "உள் இணைப்பி" + "internalLink": "உள் இணைப்பி", + "attachmentOnPage": "பக்கம் {{page}}", + "attachmentCreated": "உருவாக்கப்பட்டது: {{date}}", + "attachmentModified": "மாற்றப்பட்டது: {{date}}", + "openAttachmentRiskTitle": "இணைப்பைத் திற", + "openAttachmentRiskMessage": "\"{{name}}\" இந்தக் கணினியில் நிரல்களை இயக்கக்கூடும். மூலத்தை நம்பினால் மட்டுமே திறக்கவும். இருப்பினும் திறக்கவா?" }, "failedToInsertPagesFromFile": "கோப்பிலிருந்து பக்கங்களைச் செருக முடியவில்லை: {{error}}", "previewPdfRefused": "இந்த PDF வரைபட இறக்குமதியின் முன்னோட்டம்; இது முழுமையற்றதாக இருக்கலாம். இதைச் செருகவோ சேமிக்கவோ முடியாது; வரைபடத்தை மீண்டும் இறக்குமதி செய்யவும்.", diff --git a/open-pdf-studio/js/i18n/locales/ta/properties.json b/open-pdf-studio/js/i18n/locales/ta/properties.json index 739665899..e0865324b 100644 --- a/open-pdf-studio/js/i18n/locales/ta/properties.json +++ b/open-pdf-studio/js/i18n/locales/ta/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "காகிதக் கிளிப்", + "iconPushPin": "குண்டூசி", + "iconGraph": "வரைபடம்", + "iconTag": "குறிச்சொல்" }, "stylePresets": { "label": "கோட்டுப் பாணி", @@ -311,7 +315,8 @@ "measurePerimeter": "சுற்றளவு", "redaction": "மறைப்பு", "scaleBar": "அளவுகோல் பட்டை", - "parametricSymbol": "அளவுரு குறியீடு" + "parametricSymbol": "அளவுரு குறியீடு", + "fileAttachment": "கோப்பு இணைப்பு" }, "leftPanel": { "togglePanel": "பேனலை மாற்று", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "செங்குத்து இடைவெளி", "inb-horizontaal-breed": "கிடைமட்டம் இடைவெளி" } + }, + "attachment": { + "file": "கோப்பு", + "size": "அளவு", + "open": "திற", + "saveAs": "இவ்வாறு சேமி...", + "noContent": "இந்தக் கோப்பின் உள்ளடக்கம் PDF இல் உட்பொதிக்கப்படவில்லை." } } diff --git a/open-pdf-studio/js/i18n/locales/ta/ribbon.json b/open-pdf-studio/js/i18n/locales/ta/ribbon.json index 23402d7ec..03c912f12 100644 --- a/open-pdf-studio/js/i18n/locales/ta/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ta/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "அனைத்தையும் தட்டையாக்கு", "flattenAllTooltip": "அனைத்து குறியீடுகளையும் பக்க உள்ளடக்கமாக தட்டையாக்கு", "unflattenAll": "தட்டையாக்கலை செயல்தவிர்", - "unflattenAllTooltip": "மீட்டெடுப்பு அனுமதியுடன் தட்டையாக்கப்பட்ட குறியீடுகளை மீட்டெடு" + "unflattenAllTooltip": "மீட்டெடுப்பு அனுமதியுடன் தட்டையாக்கப்பட்ட குறியீடுகளை மீட்டெடு", + "fileAttachment": "கோப்பை இணை", + "fileAttachmentTitle": "கோப்பு இணைப்பு: பக்கத்தில் கிளிக் செய்து ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்" }, "view": { "pageDisplay": "பக்க காட்சி", diff --git a/open-pdf-studio/js/i18n/locales/th/common.json b/open-pdf-studio/js/i18n/locales/th/common.json index 29a1d902a..fda3381ed 100644 --- a/open-pdf-studio/js/i18n/locales/th/common.json +++ b/open-pdf-studio/js/i18n/locales/th/common.json @@ -228,7 +228,12 @@ "goToPageLink": "ไปที่หน้า", "openExternalFile": "เปิด: {{filename}}", "destination": "ปลายทาง: {{name}}", - "internalLink": "ลิงก์ภายใน" + "internalLink": "ลิงก์ภายใน", + "attachmentOnPage": "หน้า {{page}}", + "attachmentCreated": "สร้างเมื่อ: {{date}}", + "attachmentModified": "แก้ไขเมื่อ: {{date}}", + "openAttachmentRiskTitle": "เปิดไฟล์แนบ", + "openAttachmentRiskMessage": "\"{{name}}\" สามารถเรียกใช้โปรแกรมบนคอมพิวเตอร์เครื่องนี้ได้ เปิดเฉพาะเมื่อคุณเชื่อถือแหล่งที่มา ต้องการเปิดต่อหรือไม่?" }, "failedToInsertPagesFromFile": "แทรกหน้าจากไฟล์ไม่สำเร็จ: {{error}}", "previewPdfRefused": "PDF นี้เป็นตัวอย่างจากการนำเข้าแบบและอาจไม่ครบถ้วน ไม่สามารถแทรกหรือบันทึกได้ โปรดนำเข้าแบบอีกครั้ง", diff --git a/open-pdf-studio/js/i18n/locales/th/properties.json b/open-pdf-studio/js/i18n/locales/th/properties.json index a785d46dc..a4eb62917 100644 --- a/open-pdf-studio/js/i18n/locales/th/properties.json +++ b/open-pdf-studio/js/i18n/locales/th/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "คลิปหนีบกระดาษ", + "iconPushPin": "หมุด", + "iconGraph": "กราฟ", + "iconTag": "ป้าย" }, "stylePresets": { "label": "รูปแบบเส้น", @@ -311,7 +315,8 @@ "measurePerimeter": "เส้นรอบรูป", "redaction": "การปกปิด", "scaleBar": "แถบมาตราส่วน", - "parametricSymbol": "สัญลักษณ์พาราเมตริก" + "parametricSymbol": "สัญลักษณ์พาราเมตริก", + "fileAttachment": "ไฟล์แนบ" }, "leftPanel": { "togglePanel": "สลับแผง", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "เส้นแนวตั้งห่าง", "inb-horizontaal-breed": "เส้นแนวนอนห่าง" } + }, + "attachment": { + "file": "ไฟล์", + "size": "ขนาด", + "open": "เปิด", + "saveAs": "บันทึกเป็น...", + "noContent": "เนื้อหาของไฟล์นี้ไม่ได้ฝังอยู่ใน PDF" } } diff --git a/open-pdf-studio/js/i18n/locales/th/ribbon.json b/open-pdf-studio/js/i18n/locales/th/ribbon.json index 3b153868c..e883c1d7e 100644 --- a/open-pdf-studio/js/i18n/locales/th/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/th/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "รวมทั้งหมด", "flattenAllTooltip": "รวมมาร์กอัปทั้งหมดเข้ากับเนื้อหาหน้า", "unflattenAll": "เลิกรวม", - "unflattenAllTooltip": "กู้คืนมาร์กอัปที่รวมไว้โดยอนุญาตให้กู้คืน" + "unflattenAllTooltip": "กู้คืนมาร์กอัปที่รวมไว้โดยอนุญาตให้กู้คืน", + "fileAttachment": "แนบไฟล์", + "fileAttachmentTitle": "ไฟล์แนบ: คลิกบนหน้าแล้วเลือกไฟล์" }, "view": { "pageDisplay": "การแสดงหน้า", diff --git a/open-pdf-studio/js/i18n/locales/tr/common.json b/open-pdf-studio/js/i18n/locales/tr/common.json index 329a2f71c..2c518d38e 100644 --- a/open-pdf-studio/js/i18n/locales/tr/common.json +++ b/open-pdf-studio/js/i18n/locales/tr/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Sayfaya git", "openExternalFile": "Aç: {{filename}}", "destination": "Hedef: {{name}}", - "internalLink": "Dahili Bağlantı" + "internalLink": "Dahili Bağlantı", + "attachmentOnPage": "Sayfa {{page}}", + "attachmentCreated": "Oluşturulma: {{date}}", + "attachmentModified": "Değiştirilme: {{date}}", + "openAttachmentRiskTitle": "Eki aç", + "openAttachmentRiskMessage": "\"{{name}}\" bu bilgisayarda program çalıştırabilir. Yalnızca kaynağa güveniyorsanız açın. Yine de açılsın mı?" }, "failedToInsertPagesFromFile": "Dosyadan sayfa eklenemedi: {{error}}", "previewPdfRefused": "Bu PDF, çizim içe aktarmanın bir önizlemesidir ve eksik olabilir. Eklenemez veya kaydedilemez; çizimi yeniden içe aktarın.", diff --git a/open-pdf-studio/js/i18n/locales/tr/properties.json b/open-pdf-studio/js/i18n/locales/tr/properties.json index 4bc370455..b3d012ce5 100644 --- a/open-pdf-studio/js/i18n/locales/tr/properties.json +++ b/open-pdf-studio/js/i18n/locales/tr/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Ataş", + "iconPushPin": "Raptiye", + "iconGraph": "Grafik", + "iconTag": "Etiket" }, "stylePresets": { "label": "Çizgi stili", @@ -311,7 +315,8 @@ "measurePerimeter": "Çevre", "redaction": "Karartma", "scaleBar": "Ölçek Çubuğu", - "parametricSymbol": "Parametrik sembol" + "parametricSymbol": "Parametrik sembol", + "fileAttachment": "Dosya eki" }, "leftPanel": { "togglePanel": "Paneli Aç/Kapat", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Dikey tarama seyrek", "inb-horizontaal-breed": "Yatay tarama seyrek" } + }, + "attachment": { + "file": "Dosya", + "size": "Boyut", + "open": "Aç", + "saveAs": "Farklı kaydet...", + "noContent": "Bu dosyanın içeriği PDF'e gömülü değil." } } diff --git a/open-pdf-studio/js/i18n/locales/tr/ribbon.json b/open-pdf-studio/js/i18n/locales/tr/ribbon.json index 8e9e904ce..5f53ed408 100644 --- a/open-pdf-studio/js/i18n/locales/tr/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/tr/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Tümünü düzleştir", "flattenAllTooltip": "Tüm işaretlemeleri sayfa içeriğine düzleştir", "unflattenAll": "Düzleştirmeyi geri al", - "unflattenAllTooltip": "Geri yüklemeye izin verilerek düzleştirilen işaretlemeleri geri yükle" + "unflattenAllTooltip": "Geri yüklemeye izin verilerek düzleştirilen işaretlemeleri geri yükle", + "fileAttachment": "Dosya ekle", + "fileAttachmentTitle": "Dosya eki: sayfaya tıklayın ve bir dosya seçin" }, "view": { "pageDisplay": "Sayfa Görüntüleme", diff --git a/open-pdf-studio/js/i18n/locales/uk/common.json b/open-pdf-studio/js/i18n/locales/uk/common.json index 7719f477d..177318efc 100644 --- a/open-pdf-studio/js/i18n/locales/uk/common.json +++ b/open-pdf-studio/js/i18n/locales/uk/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Перейти на сторінку", "openExternalFile": "Відкрити: {{filename}}", "destination": "Призначення: {{name}}", - "internalLink": "Внутрішнє посилання" + "internalLink": "Внутрішнє посилання", + "attachmentOnPage": "Сторінка {{page}}", + "attachmentCreated": "Створено: {{date}}", + "attachmentModified": "Змінено: {{date}}", + "openAttachmentRiskTitle": "Відкрити вкладення", + "openAttachmentRiskMessage": "«{{name}}» може запускати програми на цьому комп'ютері. Відкривайте його, лише якщо довіряєте джерелу. Усе одно відкрити?" }, "failedToInsertPagesFromFile": "Не вдалося вставити сторінки з файлу: {{error}}", "previewPdfRefused": "Цей PDF — попередній перегляд з імпорту креслення і може бути неповним. Його не можна вставити чи зберегти; імпортуйте креслення знову.", diff --git a/open-pdf-studio/js/i18n/locales/uk/properties.json b/open-pdf-studio/js/i18n/locales/uk/properties.json index 76ab05baa..4f7b4caa7 100644 --- a/open-pdf-studio/js/i18n/locales/uk/properties.json +++ b/open-pdf-studio/js/i18n/locales/uk/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Скріпка", + "iconPushPin": "Кнопка", + "iconGraph": "Діаграма", + "iconTag": "Ярлик" }, "stylePresets": { "label": "Стиль лінії", @@ -311,7 +315,8 @@ "measurePerimeter": "Периметр", "redaction": "Редагування тексту", "scaleBar": "Масштабна лінійка", - "parametricSymbol": "Параметричний символ" + "parametricSymbol": "Параметричний символ", + "fileAttachment": "Вкладений файл" }, "leftPanel": { "togglePanel": "Перемкнути панель", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Вертикальне штрихування рідке", "inb-horizontaal-breed": "Горизонтальне штрихування рідке" } + }, + "attachment": { + "file": "Файл", + "size": "Розмір", + "open": "Відкрити", + "saveAs": "Зберегти як...", + "noContent": "Вміст цього файлу не вбудовано в PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/uk/ribbon.json b/open-pdf-studio/js/i18n/locales/uk/ribbon.json index ab03069c5..5e710aebe 100644 --- a/open-pdf-studio/js/i18n/locales/uk/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/uk/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Звести все", "flattenAllTooltip": "Звести всю розмітку у вміст сторінки", "unflattenAll": "Скасувати зведення", - "unflattenAllTooltip": "Відновити розмітку, зведену з дозволеним відновленням" + "unflattenAllTooltip": "Відновити розмітку, зведену з дозволеним відновленням", + "fileAttachment": "Прикріпити файл", + "fileAttachmentTitle": "Вкладений файл: клацніть на сторінці та виберіть файл" }, "view": { "pageDisplay": "Відображення сторінки", diff --git a/open-pdf-studio/js/i18n/locales/ur/common.json b/open-pdf-studio/js/i18n/locales/ur/common.json index abb269c18..db1b044d4 100644 --- a/open-pdf-studio/js/i18n/locales/ur/common.json +++ b/open-pdf-studio/js/i18n/locales/ur/common.json @@ -228,7 +228,12 @@ "goToPageLink": "صفحے پر جائیں", "openExternalFile": "کھولیں: {{filename}}", "destination": "منزل: {{name}}", - "internalLink": "اندرونی لنک" + "internalLink": "اندرونی لنک", + "attachmentOnPage": "صفحہ {{page}}", + "attachmentCreated": "تخلیق: {{date}}", + "attachmentModified": "ترمیم: {{date}}", + "openAttachmentRiskTitle": "منسلکہ کھولیں", + "openAttachmentRiskMessage": "\"{{name}}\" اس کمپیوٹر پر پروگرام چلا سکتا ہے۔ اسے صرف اسی صورت کھولیں جب آپ کو ماخذ پر بھروسہ ہو۔ پھر بھی کھولیں؟" }, "failedToInsertPagesFromFile": "فائل سے صفحات داخل کرنے میں ناکامی: {{error}}", "previewPdfRefused": "یہ PDF ڈرائنگ کی درآمد کا پیش منظر ہے اور نامکمل ہو سکتی ہے۔ اسے نہ شامل کیا جا سکتا ہے نہ محفوظ؛ ڈرائنگ دوبارہ درآمد کریں۔", diff --git a/open-pdf-studio/js/i18n/locales/ur/properties.json b/open-pdf-studio/js/i18n/locales/ur/properties.json index f5a33135b..ec41a4624 100644 --- a/open-pdf-studio/js/i18n/locales/ur/properties.json +++ b/open-pdf-studio/js/i18n/locales/ur/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "پیپر کلپ", + "iconPushPin": "پن", + "iconGraph": "گراف", + "iconTag": "ٹیگ" }, "stylePresets": { "label": "لکیر کا انداز", @@ -311,7 +315,8 @@ "measurePerimeter": "فریم", "redaction": "ریڈیکشن", "scaleBar": "اسکیل بار", - "parametricSymbol": "پیرامیٹرک علامت" + "parametricSymbol": "پیرامیٹرک علامت", + "fileAttachment": "فائل منسلکہ" }, "leftPanel": { "togglePanel": "پینل ٹوگل کریں", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "عمودی کشادہ", "inb-horizontaal-breed": "افقی کشادہ" } + }, + "attachment": { + "file": "فائل", + "size": "سائز", + "open": "کھولیں", + "saveAs": "بطور محفوظ کریں...", + "noContent": "اس فائل کا مواد PDF میں شامل نہیں ہے۔" } } diff --git a/open-pdf-studio/js/i18n/locales/ur/ribbon.json b/open-pdf-studio/js/i18n/locales/ur/ribbon.json index a702d8036..d244df41b 100644 --- a/open-pdf-studio/js/i18n/locales/ur/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/ur/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "سب ہموار کریں", "flattenAllTooltip": "تمام مارک اپس کو صفحے کے مواد میں ہموار کریں", "unflattenAll": "ہموار کرنا واپس لیں", - "unflattenAllTooltip": "وہ مارک اپس بحال کریں جو بحالی کی اجازت کے ساتھ ہموار کیے گئے تھے" + "unflattenAllTooltip": "وہ مارک اپس بحال کریں جو بحالی کی اجازت کے ساتھ ہموار کیے گئے تھے", + "fileAttachment": "فائل منسلک کریں", + "fileAttachmentTitle": "فائل منسلکہ: صفحے پر کلک کریں اور ایک فائل منتخب کریں" }, "view": { "pageDisplay": "صفحہ کی نمائش", diff --git a/open-pdf-studio/js/i18n/locales/vi/common.json b/open-pdf-studio/js/i18n/locales/vi/common.json index d053356b5..1fb786dab 100644 --- a/open-pdf-studio/js/i18n/locales/vi/common.json +++ b/open-pdf-studio/js/i18n/locales/vi/common.json @@ -228,7 +228,12 @@ "goToPageLink": "Đi đến trang", "openExternalFile": "Mở: {{filename}}", "destination": "Đích đến: {{name}}", - "internalLink": "Liên kết nội bộ" + "internalLink": "Liên kết nội bộ", + "attachmentOnPage": "Trang {{page}}", + "attachmentCreated": "Tạo: {{date}}", + "attachmentModified": "Sửa đổi: {{date}}", + "openAttachmentRiskTitle": "Mở tệp đính kèm", + "openAttachmentRiskMessage": "\"{{name}}\" có thể chạy chương trình trên máy tính này. Chỉ mở nếu bạn tin tưởng nguồn gốc. Vẫn mở?" }, "failedToInsertPagesFromFile": "Chèn trang từ tệp thất bại: {{error}}", "previewPdfRefused": "PDF này là bản xem trước từ việc nhập bản vẽ và có thể chưa đầy đủ. Không thể chèn hoặc lưu nó; hãy nhập lại bản vẽ.", diff --git a/open-pdf-studio/js/i18n/locales/vi/properties.json b/open-pdf-studio/js/i18n/locales/vi/properties.json index 0c0b87253..57f8e06b0 100644 --- a/open-pdf-studio/js/i18n/locales/vi/properties.json +++ b/open-pdf-studio/js/i18n/locales/vi/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "Kẹp giấy", + "iconPushPin": "Ghim", + "iconGraph": "Biểu đồ", + "iconTag": "Thẻ" }, "stylePresets": { "label": "Kiểu đường", @@ -311,7 +315,8 @@ "measurePerimeter": "Chu vi", "redaction": "Che thông tin", "scaleBar": "Thước tỷ lệ", - "parametricSymbol": "Ký hiệu tham số" + "parametricSymbol": "Ký hiệu tham số", + "fileAttachment": "Tệp đính kèm" }, "leftPanel": { "togglePanel": "Bật/Tắt Bảng", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "Đường đứng thưa", "inb-horizontaal-breed": "Đường ngang thưa" } + }, + "attachment": { + "file": "Tệp", + "size": "Kích thước", + "open": "Mở", + "saveAs": "Lưu thành...", + "noContent": "Nội dung của tệp này không được nhúng trong PDF." } } diff --git a/open-pdf-studio/js/i18n/locales/vi/ribbon.json b/open-pdf-studio/js/i18n/locales/vi/ribbon.json index 5fab45bb6..bd1bbdaa4 100644 --- a/open-pdf-studio/js/i18n/locales/vi/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/vi/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "Làm phẳng tất cả", "flattenAllTooltip": "Làm phẳng tất cả đánh dấu vào nội dung trang", "unflattenAll": "Hoàn tác làm phẳng", - "unflattenAllTooltip": "Khôi phục các đánh dấu đã làm phẳng khi cho phép khôi phục" + "unflattenAllTooltip": "Khôi phục các đánh dấu đã làm phẳng khi cho phép khôi phục", + "fileAttachment": "Đính kèm tệp", + "fileAttachmentTitle": "Tệp đính kèm: nhấp vào trang và chọn một tệp" }, "view": { "pageDisplay": "Hiển thị Trang", diff --git a/open-pdf-studio/js/i18n/locales/zh/common.json b/open-pdf-studio/js/i18n/locales/zh/common.json index 9effe411c..a2b0ccf08 100644 --- a/open-pdf-studio/js/i18n/locales/zh/common.json +++ b/open-pdf-studio/js/i18n/locales/zh/common.json @@ -228,7 +228,12 @@ "goToPageLink": "跳转到页面", "openExternalFile": "打开:{{filename}}", "destination": "目标:{{name}}", - "internalLink": "内部链接" + "internalLink": "内部链接", + "attachmentOnPage": "第 {{page}} 页", + "attachmentCreated": "创建时间: {{date}}", + "attachmentModified": "修改时间: {{date}}", + "openAttachmentRiskTitle": "打开附件", + "openAttachmentRiskMessage": "“{{name}}”可能会在此计算机上运行程序。只有在信任其来源时才打开。仍要打开吗?" }, "failedToInsertPagesFromFile": "从文件插入页面失败:{{error}}", "previewPdfRefused": "此 PDF 是图纸导入的预览,内容可能不完整。不能插入或保存;请重新导入图纸。", diff --git a/open-pdf-studio/js/i18n/locales/zh/properties.json b/open-pdf-studio/js/i18n/locales/zh/properties.json index 528443d53..918600e83 100644 --- a/open-pdf-studio/js/i18n/locales/zh/properties.json +++ b/open-pdf-studio/js/i18n/locales/zh/properties.json @@ -162,7 +162,11 @@ "boxShape": "Shape", "boxShapeRect": "Rectangle (sharp corners)", "boxShapeRounded": "Rectangle (rounded corners)", - "boxShapeEllipse": "Ellipse / circle" + "boxShapeEllipse": "Ellipse / circle", + "iconPaperclip": "回形针", + "iconPushPin": "图钉", + "iconGraph": "图表", + "iconTag": "标签" }, "stylePresets": { "label": "线条样式", @@ -311,7 +315,8 @@ "measurePerimeter": "周长", "redaction": "密文", "scaleBar": "比例尺", - "parametricSymbol": "参数化符号" + "parametricSymbol": "参数化符号", + "fileAttachment": "文件附件" }, "leftPanel": { "togglePanel": "切换面板", @@ -825,5 +830,12 @@ "inb-verticaal-breed": "竖线 疏", "inb-horizontaal-breed": "横线 疏" } + }, + "attachment": { + "file": "文件", + "size": "大小", + "open": "打开", + "saveAs": "另存为...", + "noContent": "此文件的内容未嵌入 PDF 中。" } } diff --git a/open-pdf-studio/js/i18n/locales/zh/ribbon.json b/open-pdf-studio/js/i18n/locales/zh/ribbon.json index 289dd7265..eecbf949c 100644 --- a/open-pdf-studio/js/i18n/locales/zh/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/zh/ribbon.json @@ -169,7 +169,9 @@ "flattenAll": "全部拼合", "flattenAllTooltip": "将所有标记拼合到页面内容中", "unflattenAll": "取消拼合", - "unflattenAllTooltip": "恢复在允许恢复时拼合的标记" + "unflattenAllTooltip": "恢复在允许恢复时拼合的标记", + "fileAttachment": "附加文件", + "fileAttachmentTitle": "文件附件: 单击页面并选择一个文件" }, "view": { "pageDisplay": "页面显示", diff --git a/open-pdf-studio/js/pdf/bijlage-annotatie.js b/open-pdf-studio/js/pdf/bijlage-annotatie.js new file mode 100644 index 000000000..fe0b26c3e --- /dev/null +++ b/open-pdf-studio/js/pdf/bijlage-annotatie.js @@ -0,0 +1,162 @@ +// Bijlage-annotaties (/FileAttachment, ISO 32000 12.5.6.15, #376): een +// pictogram op de pagina met een ingesloten bestand erachter. +// +// - bouwBijlageAnnotatie schrijft de annotatie: /Subtype /FileAttachment, +// /FS naar een bestandsspecificatie met /EF (zie maakBestandsspecificatie +// in bijlagen.js), /Name (het pictogram), /Contents (de beschrijving), /AF +// (PDF 2.0: het bestand is aan deze annotatie geassocieerd) en een eigen +// appearance, zodat elke lezer hetzelfde pictogram tekent. +// - leesBijlageAnnotatie leest dat terug, ook van een ander programma. +// - bijlageVingerafdruk en origineleBijlage: een geladen bijlage-annotatie +// waarvan de inhoud niet te lezen is, mag bij het opslaan het ingesloten +// bestand niet kwijtraken. Onbewerkt blijft het origineel ongemoeid (met +// zijn eigen /FS, /AP en wat de app niet kent); bewerkt krijgt de nieuwe +// annotatie de oorspronkelijke bestandsspecificatie. +// +// Puur pdf-lib, geen app-imports: zo is het in node te testen. + +import { PDFName, PDFString, PDFHexString, PDFDict, PDFRef } from 'pdf-lib'; +import { maakBestandsspecificatie, leesBestandsspecificatie } from './bijlagen.js'; +import { bijlageIcoonContent, bijlageIcoonNaarPdf, bijlageIcoonUitPdf } from '../annotations/bijlage-iconen.js'; +import { refVanPdfjsId } from './laag-ids.js'; + +/** + * @param {import('pdf-lib').PDFDocument} pdfDoc + * @param {object} p + * @param {number[]} p.rect [x1, y1, x2, y2] in PDF-ruimte + * @param {string} [p.icoon] paperclip | pushpin | graph | tag + * @param {string} [p.kleur] #rrggbb + * @param {number[]} [p.kleurArray] /C + * @param {number} [p.opacity] + * @param {string} [p.tekst] /Contents + * @param {import('pdf-lib').PDFObject} [p.auteur] /T + * @param {number} [p.flags] /F + * @param {object} p.bestand { naam, bytes | ruw, mime, beschrijving, aangemaakt, gewijzigd } + * @param {(s:string)=>import('pdf-lib').PDFObject} p.tekstObj tekst als PDF-tekenreeks + * @param {import('pdf-lib').PDFObject} [p.bestaandeFs] de oorspronkelijke + * bestandsspecificatie, voor als de inhoud niet te lezen was + * @returns {import('pdf-lib').PDFDict|null} null zonder bestandsnaam + */ +export function bouwBijlageAnnotatie(pdfDoc, p) { + const { context } = pdfDoc; + const b = p.bestand; + if (!b || !b.naam) return null; + // Zonder inhoud de oorspronkelijke bestandsspecificatie (zie de kop), en + // anders (een verwijzing naar een extern bestand) een zonder /EF: /FS is + // verplicht. + const fs = (b.bytes || b.ruw) ? maakBestandsspecificatie(pdfDoc, b) : p.bestaandeFs ? p.bestaandeFs : context.register(context.obj({ + Type: 'Filespec', F: PDFHexString.fromText(b.naam), UF: PDFHexString.fromText(b.naam), + Desc: PDFHexString.fromText(b.beschrijving || b.naam), + })); + const [x1, y1, x2, y2] = p.rect; + const w = Math.abs(x2 - x1) || 24; + const h = Math.abs(y2 - y1) || 24; + const rect = [Math.min(x1, x2), Math.min(y1, y2), Math.min(x1, x2) + w, Math.min(y1, y2) + h]; + const ap = context.register(context.stream(bijlageIcoonContent(p.icoon, w, h, p.kleur), { + Type: 'XObject', Subtype: 'Form', BBox: [0, 0, w, h], + })); + const dict = context.obj({ + Type: 'Annot', + Subtype: 'FileAttachment', + Rect: rect, + FS: fs, + Name: bijlageIcoonNaarPdf(p.icoon), + Contents: p.tekstObj(p.tekst || b.beschrijving || b.naam), + C: p.kleurArray || [0.17, 0.34, 0.6], + CA: p.opacity ?? 1, + M: PDFString.fromDate(new Date()), + F: p.flags ?? 4, + AF: [fs], + AP: { N: ap }, + }); + if (p.auteur) dict.set(PDFName.of('T'), p.auteur); + // /AF bevat bestandsspecificaties; een /FS als losse tekenreeks hoort er niet in. + if (!(fs instanceof PDFRef || fs instanceof PDFDict)) dict.delete(PDFName.of('AF')); + return dict; +} + +const rond = (v) => (typeof v === 'number' ? Math.round(v * 1000) / 1000 : v ?? null); + +// Wat de gebruiker aan een bijlage-annotatie kan veranderen en wat de saver +// ervan schrijft. Antwoorden en reviewstatus horen er niet bij: die gaan als +// eigen /Text-annotaties met /IRT het bestand in, ook naast een origineel. +const BEWERKBAAR = [ + 'x', 'y', 'width', 'height', 'rotation', 'icon', 'color', 'opacity', 'text', 'subject', 'pdfSubject', + 'author', 'locked', 'printable', 'readOnly', 'hidden', 'layer', 'altText', 'ifcCategory', 'spatialZone', + 'locationOverride', 'blendMode', 'nm', +]; +const BESTAND = ['name', 'description', 'mime', 'size', 'createdAt', 'modifiedAt', 'relationship', 'external']; + +/** Vingerafdruk van wat aan een bijlage-annotatie te bewerken is (zie de kop). */ +export function bijlageVingerafdruk(ann) { + const a = ann || {}; + const b = a.attachment || {}; + return JSON.stringify([BEWERKBAAR.map((k) => rond(a[k])), BESTAND.map((k) => rond(b[k]))]); +} + +/** + * Waar een geladen bijlage-annotatie vandaan kwam: het object van de + * annotatie (pdf.js-id) en de vingerafdruk zoals geladen. Ook de + * bestandsspecificatie, als die een eigen object is. + */ +export function bijlageBron(ann, pdfjsId, fsRef = null) { + return { ref: pdfjsId || null, fs: fsRef || null, staat: bijlageVingerafdruk(ann) }; +} + +/** Is een geladen bijlage-annotatie sinds het laden veranderd? Nieuw telt als veranderd. */ +export function bijlageBewerkt(ann) { + const bron = ann?.attachment?.bron; + return !bron || bron.staat !== bijlageVingerafdruk(ann); +} + +function refUitId(id) { + const r = refVanPdfjsId(id); + return r ? PDFRef.of(r.nummer, r.generatie) : null; +} + +/** + * Het origineel van een geladen bijlage-annotatie in het uitvoerdocument. + * @param {import('pdf-lib').PDFContext} context + * @param {PDFRef[]} kandidaten de bijlage-annotaties van de pagina die het + * model vervangt (de saver haalt ze weg en schrijft het model opnieuw) + * @param {object} ann het model + * @returns {null|{ref: PDFRef|null, fs: import('pdf-lib').PDFObject}} + * ref: de annotatie zelf als die nog op de pagina staat; fs: haar + * bestandsspecificatie (of die van het oorspronkelijke /FS-object) + */ +export function origineleBijlage(context, kandidaten, ann) { + const bron = ann?.attachment?.bron; + if (!bron) return null; + const doel = refUitId(bron.ref); + const nm = ann.nm != null ? String(ann.nm) : ''; + for (const ref of kandidaten || []) { + if (!(ref instanceof PDFRef)) continue; + const dict = context.lookup(ref); + if (!(dict instanceof PDFDict) || dict.lookup(PDFName.of('Subtype'))?.toString() !== '/FileAttachment') continue; + const opRef = doel && ref.objectNumber === doel.objectNumber && ref.generationNumber === doel.generationNumber; + const nmVal = dict.lookup(PDFName.of('NM')); + const opNaam = nm && typeof nmVal?.decodeText === 'function' && nmVal.decodeText() === nm; + if (!opRef && !opNaam) continue; + const fs = dict.get(PDFName.of('FS')); + if (fs !== undefined) return { ref, fs }; + } + // De annotatie zelf is er niet meer (bijvoorbeeld al eens herschreven): + // dan nog haar bestandsspecificatie, als die nog een bestand met /EF is. + const fsRef = refUitId(bron.fs); + const fs = fsRef ? context.lookup(fsRef) : null; + if (fs instanceof PDFDict && fs.lookup(PDFName.of('EF')) instanceof PDFDict) return { ref: null, fs: fsRef }; + return null; +} + +/** + * Het bestand en het pictogram van een /FileAttachment-woordenboek. + * @returns {null|{icoon:string, bestand:ReturnType}} + */ +export function leesBijlageAnnotatie(context, annotDict) { + if (!(annotDict instanceof PDFDict)) return null; + if (annotDict.lookup(PDFName.of('Subtype'))?.toString() !== '/FileAttachment') return null; + const bestand = leesBestandsspecificatie(context, annotDict.get(PDFName.of('FS'))); + if (!bestand) return null; + const naam = annotDict.lookup(PDFName.of('Name')); + return { icoon: bijlageIcoonUitPdf(naam instanceof PDFName ? naam.decodeText() : 'PushPin'), bestand }; +} diff --git a/open-pdf-studio/js/pdf/bijlage-annotatie.test.mjs b/open-pdf-studio/js/pdf/bijlage-annotatie.test.mjs new file mode 100644 index 000000000..cc12b461a --- /dev/null +++ b/open-pdf-studio/js/pdf/bijlage-annotatie.test.mjs @@ -0,0 +1,356 @@ +// Bijlagen volgens de PDF-specificatie (#376): wat de app schrijft, lezen +// pdf-lib, pdf.js en MuPDF; wat een ander programma schrijft (ook een naamboom +// met /Kids), leest en verwijdert de app. + +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { + PDFDocument, PDFName, PDFDict, PDFArray, PDFHexString, PDFString, PDFRawStream, PDFStream, +} from 'pdf-lib'; +import * as mupdf from 'mupdf'; +import { pasBijlagenToe, leesBijlageNamen, leesBestandsspecificatie, metToegevoegd, metVerwijderd } from './bijlagen.js'; +import { bouwBijlageAnnotatie, leesBijlageAnnotatie } from './bijlage-annotatie.js'; +import { bijlageIcoonContent, bijlageIcoonUitPdf, bijlageIcoonNaarPdf, BIJLAGE_ICONEN } from '../annotations/bijlage-iconen.js'; + +const N = (n) => PDFName.of(n); +const tekst = (s) => new TextEncoder().encode(s); +const lees = (b) => new TextDecoder().decode(b); +const leeg = { toegevoegd: [], verwijderd: [] }; + +async function pdfMetBijlage(bijlage) { + const pdf = await PDFDocument.create(); + pdf.addPage([300, 300]); + pasBijlagenToe(pdf, metToegevoegd(leeg, bijlage)); + return pdf.save(); +} + +async function pdfMetBijlageAnnotatie(p) { + const pdf = await PDFDocument.create(); + const pagina = pdf.addPage([300, 300]); + const dict = bouwBijlageAnnotatie(pdf, { tekstObj: (s) => PDFHexString.fromText(s), ...p }); + pagina.node.set(N('Annots'), pdf.context.obj([pdf.context.register(dict)])); + return pdf.save(); +} + +function mupdfDoc(bytes) { + return mupdf.Document.openDocument(bytes, 'application/pdf').asPDF(); +} + +test('a document attachment has the structure of the specification (pdf-lib)', async () => { + const bytes = await pdfMetBijlage({ + naam: 'rapport.txt', bytes: tekst('berekening'), mime: 'text/plain', beschrijving: 'Berekening', + aangemaakt: '2026-10-01T08:00:00Z', gewijzigd: '2026-10-02T09:30:00Z', + }); + const pdf = await PDFDocument.load(bytes); + const ctx = pdf.context; + const ef = pdf.catalog.lookup(N('Names')).lookup(N('EmbeddedFiles')); + const namen = ef.lookup(N('Names')); + assert.ok(namen instanceof PDFArray); + assert.equal(namen.lookup(0).decodeText(), 'rapport.txt'); + const fs = namen.lookup(1); + assert.equal(fs.lookup(N('Type')).toString(), '/Filespec'); + assert.equal(fs.lookup(N('UF')).decodeText(), 'rapport.txt'); + // /F: een bytestring, in ASCII zonder UTF-16-BOM. + assert.equal(fs.lookup(N('F')).decodeText(), 'rapport.txt'); + assert.equal(fs.lookup(N('F')).asBytes()[0], 'r'.charCodeAt(0)); + assert.equal(fs.lookup(N('Desc')).decodeText(), 'Berekening'); + const stroom = fs.lookup(N('EF')).lookup(N('F')); + assert.ok(stroom instanceof PDFStream); + assert.equal(stroom, fs.lookup(N('EF')).lookup(N('UF'))); + assert.equal(stroom.dict.lookup(N('Type')).toString(), '/EmbeddedFile'); + assert.equal(stroom.dict.lookup(N('Subtype')).decodeText(), 'text/plain'); + const params = stroom.dict.lookup(N('Params')); + assert.equal(params.lookup(N('Size')).asNumber(), 10); + assert.ok(params.lookup(N('CreationDate'))); + assert.ok(params.lookup(N('ModDate'))); + // Het bestand is geassocieerd met het document (/AF, PDF 2.0 / PDF/A-3). + assert.equal(ctx.lookup(pdf.catalog.lookup(N('AF')).get(0)), fs); + // En terug te lezen. + const gelezen = leesBestandsspecificatie(ctx, namen.get(1)); + assert.equal(lees(gelezen.bytes), 'berekening'); + assert.equal(gelezen.mime, 'text/plain'); + assert.equal(gelezen.grootte, 10); + assert.equal(gelezen.aangemaakt, '2026-10-01T08:00:00.000Z'); + assert.equal(gelezen.gewijzigd, '2026-10-02T09:30:00.000Z'); +}); + +test('MuPDF lists and extracts a document attachment written by the app', async () => { + const bytes = await pdfMetBijlage({ naam: 'model.ifc', bytes: tekst('ISO-10303-21;'), mime: 'application/x-step', beschrijving: 'IFC-model' }); + const doc = mupdfDoc(bytes); + const lijst = doc.getEmbeddedFiles(); + assert.deepEqual(Object.keys(lijst), ['model.ifc']); + const fs = lijst['model.ifc']; + assert.equal(doc.isEmbeddedFile(fs), true); + assert.equal(lees(doc.getEmbeddedFileContents(fs).asUint8Array()), 'ISO-10303-21;'); + const params = doc.getFilespecParams(fs); + assert.equal(params.filename, 'model.ifc'); + assert.equal(params.mimetype, 'application/x-step'); +}); + +test('a file attachment annotation is written per the specification and read by pdf.js and MuPDF', async () => { + const bytes = await pdfMetBijlageAnnotatie({ + rect: [40, 200, 64, 224], icoon: 'paperclip', kleur: '#2B579A', tekst: 'Zie berekening', + bestand: { naam: 'calc.csv', bytes: tekst('a;b\n1;2\n'), mime: 'text/csv', beschrijving: 'Berekening' }, + }); + + // pdf-lib: de structuur. + const pdf = await PDFDocument.load(bytes); + const ctx = pdf.context; + const annot = ctx.lookup(pdf.getPage(0).node.Annots().get(0)); + assert.equal(annot.lookup(N('Subtype')).toString(), '/FileAttachment'); + assert.equal(annot.lookup(N('Name')).toString(), '/Paperclip'); + assert.equal(annot.lookup(N('Contents')).decodeText(), 'Zie berekening'); + const fs = annot.lookup(N('FS')); + assert.equal(fs.lookup(N('Type')).toString(), '/Filespec'); + assert.ok(fs.lookup(N('EF')).lookup(N('F')) instanceof PDFStream); + assert.equal(ctx.lookup(annot.lookup(N('AF')).get(0)), fs); + assert.ok(ctx.lookup(annot.lookup(N('AP')).get(N('N'))) instanceof PDFStream, 'own appearance'); + // Annotatiebijlagen horen niet in de naamboom van het document. + assert.deepEqual(leesBijlageNamen(pdf), []); + const terug = leesBijlageAnnotatie(ctx, annot); + assert.equal(terug.icoon, 'paperclip'); + assert.equal(terug.bestand.naam, 'calc.csv'); + assert.equal(terug.bestand.beschrijving, 'Berekening'); + assert.equal(lees(terug.bestand.bytes), 'a;b\n1;2\n'); + + // pdf.js: soort, bestandsnaam en inhoud. + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const pdfjs = await getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; + try { + const [a] = await (await pdfjs.getPage(1)).getAnnotations(); + assert.equal(a.subtype, 'FileAttachment'); + assert.equal(a.file.filename, 'calc.csv'); + assert.equal(a.name, 'Paperclip'); + assert.equal(lees(await pdfjs.getAttachmentContent(a.fileId)), 'a;b\n1;2\n'); + } finally { + await pdfjs.loadingTask.destroy(); + } + + // MuPDF: de annotatie en haar bestand. + const doc = mupdfDoc(bytes); + const [ma] = doc.loadPage(0).getAnnotations(); + assert.equal(ma.getType(), 'FileAttachment'); + const mfs = ma.getFileSpec(); + assert.equal(doc.isEmbeddedFile(mfs), true); + assert.equal(lees(doc.getEmbeddedFileContents(mfs).asUint8Array()), 'a;b\n1;2\n'); + assert.equal(doc.getFilespecParams(mfs).filename, 'calc.csv'); +}); + +test('attachments written by MuPDF are listed and extracted', async () => { + const doc = new mupdf.PDFDocument(); + doc.insertPage(-1, doc.addPage([0, 0, 300, 300], 0, {}, '')); + const datum = new Date('2026-09-01T12:00:00Z'); + const efs = doc.addEmbeddedFile('tekening.dwg', 'application/acad', tekst('AC1027'), datum, datum); + doc.insertEmbeddedFile('tekening.dwg', efs); + const pagina = doc.loadPage(0); + const annot = pagina.createAnnotation('FileAttachment'); + annot.setRect([20, 20, 40, 40]); + annot.setFileSpec(doc.addEmbeddedFile('foto.jpg', 'image/jpeg', tekst('JFIF'), datum, datum)); + annot.update(); + const bytes = doc.saveToBuffer('compress').asUint8Array(); + + const pdf = await PDFDocument.load(bytes); + assert.deepEqual(leesBijlageNamen(pdf), ['tekening.dwg']); + const ef = pdf.catalog.lookup(N('Names')).lookup(N('EmbeddedFiles')); + const namen = []; + const verzamel = (k) => { + const n = k.lookup(N('Names')); + if (n) for (let i = 0; i < n.size(); i += 2) namen.push(n.get(i + 1)); + const kids = k.lookup(N('Kids')); + if (kids) for (let i = 0; i < kids.size(); i++) verzamel(kids.lookup(i)); + }; + verzamel(ef); + const doc1 = leesBestandsspecificatie(pdf.context, namen[0]); + assert.equal(doc1.naam, 'tekening.dwg'); + assert.equal(lees(doc1.bytes), 'AC1027'); + assert.equal(doc1.mime, 'application/acad'); + assert.equal(doc1.aangemaakt, '2026-09-01T12:00:00.000Z'); + + const annotDict = pdf.context.lookup(pdf.getPage(0).node.Annots().get(0)); + const terug = leesBijlageAnnotatie(pdf.context, annotDict); + assert.equal(terug.bestand.naam, 'foto.jpg'); + assert.equal(lees(terug.bestand.bytes), 'JFIF'); + // MuPDF schrijft een /Name of niet; zonder /Name is het de punaise (spec). + assert.ok(BIJLAGE_ICONEN.includes(terug.icoon)); +}); + +// Een naamboom zoals grotere documenten hem hebben: een wortel met /Kids, +// bladen met /Limits, PDFDocEncoding-sleutels, een bestandsspecificatie met +// alleen /F en een ongecomprimeerde stroom zonder /Params. +async function pdfMetNaamboomKids() { + const pdf = await PDFDocument.create(); + pdf.addPage([200, 200]); + const ctx = pdf.context; + const spec = (naam, inhoud, alleenF = false) => { + const stroom = ctx.register(PDFRawStream.of(ctx.obj({ Type: 'EmbeddedFile', Length: inhoud.length }), tekst(inhoud))); + return ctx.register(ctx.obj(alleenF + ? { Type: 'Filespec', F: PDFString.of(naam), EF: { F: stroom } } + : { Type: 'Filespec', F: PDFString.of(naam), UF: PDFHexString.fromText(naam), EF: { F: stroom }, Desc: PDFString.of(`over ${naam}`) })); + }; + const blad1 = ctx.register(ctx.obj({ + Limits: [PDFString.of('a.txt'), PDFString.of('b.txt')], + Names: [PDFString.of('a.txt'), spec('a.txt', 'AAA'), PDFString.of('b.txt'), spec('b.txt', 'BBB', true)], + })); + const blad2 = ctx.register(ctx.obj({ + Limits: [PDFString.of('c.txt'), PDFString.of('c.txt')], + Names: [PDFString.of('c.txt'), spec('sub/dir/c.txt', 'CCC')], + })); + const midden = ctx.register(ctx.obj({ Kids: [blad2], Limits: [PDFString.of('c.txt'), PDFString.of('c.txt')] })); + const wortel = ctx.register(ctx.obj({ Kids: [blad1, midden] })); + pdf.catalog.set(N('Names'), ctx.obj({ EmbeddedFiles: wortel })); + return pdf.save(); +} + +test('a nested name tree with /Kids from another tool is listed and extracted', async () => { + const bytes = await pdfMetNaamboomKids(); + const pdf = await PDFDocument.load(bytes); + assert.deepEqual(leesBijlageNamen(pdf), ['a.txt', 'b.txt', 'c.txt']); + + // Zoals het paneel ze ziet (pdf.js) met inhoud. + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const pdfjs = await getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; + try { + const lijst = await pdfjs.getAttachments(); + const namen = [...(lijst instanceof Map ? lijst.keys() : Object.keys(lijst))]; + assert.deepEqual(namen.sort(), ['a.txt', 'b.txt', 'c.txt']); + assert.equal(lees(await pdfjs.getAttachmentContent('c.txt')), 'CCC'); + assert.equal(lees(await pdfjs.getAttachmentContent('b.txt')), 'BBB'); + } finally { + await pdfjs.loadingTask.destroy(); + } + + // De lezer van de app: /F zonder /UF, een pad wordt de bestandsnaam. + const ef = pdf.catalog.lookup(N('Names')).lookup(N('EmbeddedFiles')); + const blad1 = ef.lookup(N('Kids')).lookup(0); + const b = leesBestandsspecificatie(pdf.context, blad1.lookup(N('Names')).get(3)); + assert.equal(b.naam, 'b.txt'); + assert.equal(lees(b.bytes), 'BBB'); + assert.equal(b.grootte, 3); + const blad2 = ef.lookup(N('Kids')).lookup(1).lookup(N('Kids')).lookup(0); + const c = leesBestandsspecificatie(pdf.context, blad2.lookup(N('Names')).get(1)); + assert.equal(c.naam, 'c.txt'); + assert.equal(c.beschrijving, 'over sub/dir/c.txt'); +}); + +test('removing from a nested name tree keeps the others and MuPDF still reads them', async () => { + const pdf = await PDFDocument.load(await pdfMetNaamboomKids()); + assert.equal(pasBijlagenToe(pdf, metVerwijderd(leeg, 'b.txt')), true); + const bytes = await pdf.save(); + const heropend = await PDFDocument.load(bytes); + assert.deepEqual(leesBijlageNamen(heropend), ['a.txt', 'c.txt']); + // De inhoud van het verwijderde bestand staat niet meer in het bestand. + assert.equal(lees(bytes).includes('BBB'), false); + const doc = mupdfDoc(bytes); + const lijst = doc.getEmbeddedFiles(); + assert.deepEqual(Object.keys(lijst).sort(), ['a.txt', 'c.txt']); + assert.equal(lees(doc.getEmbeddedFileContents(lijst['c.txt']).asUint8Array()), 'CCC'); +}); + +test('an undecodable stream travels back unchanged with its own filter', async () => { + const pdf = await PDFDocument.create(); + pdf.addPage([200, 200]); + const ctx = pdf.context; + // /JBIG2Decode kan pdf-lib niet decoderen. + const ruweBytes = new Uint8Array([1, 2, 3, 4, 5]); + const stroom = ctx.register(PDFRawStream.of(ctx.obj({ Type: 'EmbeddedFile', Filter: 'JBIG2Decode', Length: 5 }), ruweBytes)); + const fs = ctx.obj({ Type: 'Filespec', F: PDFString.of('scan.bin'), EF: { F: stroom } }); + const gelezen = leesBestandsspecificatie(ctx, fs); + assert.equal(gelezen.bytes, undefined); + assert.deepEqual([...gelezen.ruw.bytes], [1, 2, 3, 4, 5]); + assert.equal(gelezen.ruw.filter, 'JBIG2Decode'); + + const bytes = await pdfMetBijlageAnnotatie({ rect: [0, 0, 20, 20], bestand: { naam: 'scan.bin', ruw: gelezen.ruw } }); + const terug = await PDFDocument.load(bytes); + const annot = terug.context.lookup(terug.getPage(0).node.Annots().get(0)); + const s = annot.lookup(N('FS')).lookup(N('EF')).lookup(N('F')); + assert.equal(s.dict.lookup(N('Filter')).toString(), '/JBIG2Decode'); + assert.deepEqual([...s.getContents()], [1, 2, 3, 4, 5]); +}); + +test('a /FS that is a plain string (external file) is read as a name only', async () => { + const pdf = await PDFDocument.create(); + const g = leesBestandsspecificatie(pdf.context, PDFString.of('C:\\\\projecten\\\\plan.pdf')); + assert.equal(g.naam, 'plan.pdf'); + assert.equal(g.extern, true); + assert.equal(g.bytes, undefined); +}); + +test('icons: the four names of the specification, unknown becomes the paperclip', () => { + assert.deepEqual(BIJLAGE_ICONEN, ['paperclip', 'pushpin', 'graph', 'tag']); + for (const icoon of BIJLAGE_ICONEN) assert.equal(bijlageIcoonUitPdf(bijlageIcoonNaarPdf(icoon)), icoon); + assert.equal(bijlageIcoonUitPdf('PushPin'), 'pushpin'); + assert.equal(bijlageIcoonUitPdf('Onbekend'), 'paperclip'); + for (const icoon of BIJLAGE_ICONEN) { + const inhoud = bijlageIcoonContent(icoon, 24, 24, '#ff0000'); + assert.match(inhoud, /^q\n/); + assert.match(inhoud, /1 0 0 RG/); + assert.match(inhoud, /\nQ$/); + assert.doesNotMatch(inhoud, /NaN|undefined/); + } +}); + +test('the appearance of an attachment annotation is a form XObject with a BBox of the rect size', async () => { + const bytes = await pdfMetBijlageAnnotatie({ rect: [100, 100, 120, 130], icoon: 'tag', bestand: { naam: 'x.txt', bytes: tekst('x') } }); + const pdf = await PDFDocument.load(bytes); + const annot = pdf.context.lookup(pdf.getPage(0).node.Annots().get(0)); + const ap = pdf.context.lookup(annot.lookup(N('AP')).get(N('N'))); + assert.deepEqual(ap.dict.lookup(N('BBox')).asArray().map((n) => n.asNumber()), [0, 0, 20, 30]); + assert.equal(ap.dict.lookup(N('Subtype')).toString(), '/Form'); + assert.ok(annot.lookup(N('M')) instanceof PDFString); + assert.ok(annot instanceof PDFDict); +}); + +test('closing a document releases its attachment content, unless another document or the clipboard uses it', async () => { + const store = await import('../annotations/bijlage-store.js'); + store.wisBijlageStore(); + const eigen = store.bewaarBijlageInhoud({ bytes: new Uint8Array([1]) }); + const gedeeld = store.bewaarBijlageInhoud({ bytes: new Uint8Array([2]) }); + const klembord = store.bewaarBijlageInhoud({ bytes: new Uint8Array([3]) }); + const inUndo = store.bewaarBijlageInhoud({ bytes: new Uint8Array([4]) }); + const los = store.bewaarBijlageInhoud({ bytes: new Uint8Array([5]) }); + const ann = (key) => ({ type: 'fileAttachment', attachment: { key } }); + const dicht = { + annotations: [ann(eigen), ann(gedeeld), ann(klembord)], + undoStack: [{ type: 'delete', annotation: ann(inUndo) }], + }; + const open = [{ annotations: [ann(gedeeld)], undoStack: [] }]; + assert.equal(store.geefBijlagenVrij(dicht, open, [ann(klembord), null]), 2); + assert.equal(store.bijlageItem(eigen), null); + assert.equal(store.bijlageItem(inUndo), null, 'also what only its undo history held'); + assert.ok(store.bijlageItem(gedeeld), 'still used by an open document'); + assert.ok(store.bijlageItem(klembord), 'still on the clipboard'); + assert.ok(store.bijlageItem(los), 'content no document refers to is not touched'); + store.wisBijlageStore(); +}); + +test('geefDocumentVrij releases the attachment content of the closed document', () => { + const release = readFileSync(new URL('./document-release.js', import.meta.url), 'utf8'); + assert.match(release, /geefBijlagenVrij\(gesloten, overgebleven, klembord\)/); + const tabs = readFileSync(new URL('../ui/chrome/tabs.js', import.meta.url), 'utf8'); + assert.match(tabs, /geefDocumentVrij\(doc, state\.documents, \{\s*klembord: \[state\.clipboardAnnotation, state\.clipboardAnnotations\],?\s*\}\)/); +}); + +test('the origin of a loaded attachment: edited or not, and where the original is', async () => { + const { bijlageBron, bijlageBewerkt, origineleBijlage } = await import('./bijlage-annotatie.js'); + const pdf = await PDFDocument.create(); + const c = pdf.context; + const fsRef = c.register(c.obj({ Type: 'Filespec', EF: { F: c.register(c.stream('x', { Type: 'EmbeddedFile' })) } })); + const annRef = c.register(c.obj({ Type: 'Annot', Subtype: 'FileAttachment', FS: fsRef, NM: PDFString.of('nm-1') })); + const model = { type: 'fileAttachment', x: 10, y: 20, width: 24, height: 24, text: 'a', attachment: { key: null, name: 'a.txt' } }; + model.attachment.bron = bijlageBron(model, `${annRef.objectNumber}R`, `${fsRef.objectNumber}R`); + assert.equal(bijlageBewerkt(model), false); + assert.equal(bijlageBewerkt({ ...model, x: 11 }), true); + assert.equal(bijlageBewerkt({ ...model, attachment: { ...model.attachment, description: 'nieuw' } }), true); + assert.equal(bijlageBewerkt({ ...model, attachment: { ...model.attachment, key: 'k', pdfjsId: 'p' } }), false, + 'the content key is not an edit'); + assert.equal(bijlageBewerkt({ type: 'fileAttachment', attachment: { name: 'n' } }), true, 'new counts as edited'); + + assert.deepEqual(origineleBijlage(c, [annRef], model), { ref: annRef, fs: fsRef }); + // Op /NM als het objectnummer niet meer klopt. + const opNaam = { ...model, nm: 'nm-1', attachment: { ...model.attachment, bron: { ...model.attachment.bron, ref: '999R' } } }; + assert.equal(origineleBijlage(c, [annRef], opNaam).ref, annRef); + // De annotatie is weg: nog wel de bestandsspecificatie met /EF. + assert.deepEqual(origineleBijlage(c, [], model), { ref: null, fs: fsRef }); + assert.equal(origineleBijlage(c, [annRef], { ...model, attachment: { name: 'a.txt' } }), null, 'new: no original'); +}); diff --git a/open-pdf-studio/js/pdf/bijlage-rondgang.test.mjs b/open-pdf-studio/js/pdf/bijlage-rondgang.test.mjs new file mode 100644 index 000000000..8f88f0f45 --- /dev/null +++ b/open-pdf-studio/js/pdf/bijlage-rondgang.test.mjs @@ -0,0 +1,292 @@ +// Bijlage-annotaties (#376) door de echte lader en saver: laden van een +// bestand van een ander programma, verplaatsen, ander pictogram, opslaan, +// heropenen; een nieuwe annotatie; verwijderen. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { register } from 'node:module'; +import { PDFDocument, PDFName } from 'pdf-lib'; +import * as mupdf from 'mupdf'; + +register('../core/app-test-hooks.mjs', import.meta.url); +register('./saver-test-hooks.mjs', import.meta.url); +const { installeerBrowserStubs } = await import('../core/app-test-hooks.mjs'); +installeerBrowserStubs(); +const { installeerCanvas } = await import('./tekstvak-canvas.testhulp.mjs'); +installeerCanvas(); + +const { state } = await import('../core/state.js'); +const { savePDF } = await import('./saver.js'); +const { convertPdfAnnotation } = await import('./loader/annotation-converter.js'); +const { extractAnnotationColors } = await import('./loader/color-extraction.js'); +const { bewaarBijlageInhoud, bijlageBytes } = await import('../annotations/bijlage-store.js'); +const { cloneAnnotation } = await import('../annotations/factory.js'); + +const N = (n) => PDFName.of(n); +const tekst = (s) => new TextEncoder().encode(s); +const lees = (b) => new TextDecoder().decode(b); + +/** Een blad met een bijlage-annotatie zoals MuPDF hem schrijft. */ +function vreemdBlad() { + const doc = new mupdf.PDFDocument(); + doc.insertPage(-1, doc.addPage([0, 0, 400, 400], 0, {}, '')); + const pagina = doc.loadPage(0); + const annot = pagina.createAnnotation('FileAttachment'); + annot.setRect([50, 300, 70, 320]); + annot.setContents('Wapeningsberekening'); + const datum = new Date('2026-08-15T10:00:00Z'); + annot.setFileSpec(doc.addEmbeddedFile('wapening.xlsx', 'application/vnd.ms-excel', tekst('PK-xlsx-inhoud'), datum, datum)); + annot.update(); + return doc.saveToBuffer('compress').asUint8Array(); +} + +async function leegBlad() { + const doc = await PDFDocument.create(); + doc.addPage([400, 400]); + return doc.save(); +} + +/** De modellen zoals js/pdf/loader.js ze aanmaakt. */ +async function laad(bytes) { + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const doc = await pdfjs.getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; + const pagina = await doc.getPage(1); + const viewport = pagina.getViewport({ scale: 1 }); + const kaart = await extractAnnotationColors(1, await PDFDocument.load(bytes.slice())); + const modellen = []; + for (const annot of await pagina.getAnnotations()) { + const m = await convertPdfAnnotation(annot, 1, viewport, new Map(), kaart); + if (m) modellen.push(m); + } + await doc.loadingTask.destroy(); + return modellen; +} + +async function opslaan(bronBytes, modellen) { + let uit = null; + globalThis.__saverTest = { + impl: { + getCachedPdfBytes: () => bronBytes, + writeBinaryFile: (_pad, bytes) => { uit = bytes; return true; }, + writeBinaryFileAtomic: (_pad, bytes) => { uit = bytes; return true; }, + }, + }; + state.documents = [{ + id: 'bijlage', filePath: 'C:/bijlage/bron.pdf', pdfDoc: {}, annotations: modellen, + pageRotations: {}, _annotationPagesReady: new Set([1]), textEdits: [], + }]; + state.activeDocumentIndex = 0; + assert.equal(await savePDF('C:/bijlage/uit.pdf', { zonderHandtekeningVraag: true }), true, 'opslaan gelukt'); + return uit; +} + +function bijlageAnnots(pdf) { + const annots = pdf.getPage(0).node.Annots(); + return (annots ? annots.asArray() : []).map((r) => pdf.context.lookup(r)) + .filter((d) => d.lookup(N('Subtype'))?.toString() === '/FileAttachment'); +} + +test('an attachment annotation from another tool loads with its file', async () => { + const [ann] = await laad(vreemdBlad()); + assert.equal(ann.type, 'fileAttachment'); + assert.equal(ann.attachment.name, 'wapening.xlsx'); + assert.equal(ann.attachment.mime, 'application/vnd.ms-excel'); + assert.equal(ann.text, 'Wapeningsberekening'); + assert.equal(ann.subject, '', 'Contents is the note, not duplicated as subject'); + assert.equal(lees(bijlageBytes(ann.attachment.key)), 'PK-xlsx-inhoud'); + // MuPDF rekent met y omlaag: [50 300 70 320] is /Rect [50 80 70 100], in + // de app (y omlaag vanaf de bovenkant) weer y = 300. + assert.deepEqual([ann.x, ann.y, ann.width, ann.height], [50, 300, 20, 20]); + // Het model blijft JSON: de bytes zitten niet in de annotatie. + assert.deepEqual(cloneAnnotation(ann).attachment, ann.attachment); +}); + +test('moved, re-iconed and saved: one annotation, same file, readable by other readers', async () => { + const bron = vreemdBlad(); + const modellen = await laad(bron); + modellen[0].x += 100; + modellen[0].icon = 'tag'; + modellen[0].text = 'Nieuwe beschrijving'; + const bytes = await opslaan(bron, modellen); + + const pdf = await PDFDocument.load(bytes); + const annots = bijlageAnnots(pdf); + assert.equal(annots.length, 1, 'no duplicate'); + const d = annots[0]; + assert.deepEqual(d.lookup(N('Rect')).asArray().map((n) => n.asNumber()), [150, 80, 170, 100]); + assert.equal(d.lookup(N('Name')).toString(), '/Tag'); + assert.equal(d.lookup(N('Contents')).decodeText(), 'Nieuwe beschrijving'); + + const doc = mupdf.Document.openDocument(bytes, 'application/pdf').asPDF(); + const [ma] = doc.loadPage(0).getAnnotations(); + assert.equal(ma.getType(), 'FileAttachment'); + assert.equal(lees(doc.getEmbeddedFileContents(ma.getFileSpec()).asUint8Array()), 'PK-xlsx-inhoud'); + assert.equal(doc.getFilespecParams(ma.getFileSpec()).filename, 'wapening.xlsx'); + + // En de app leest het weer zoals het was. + const [terug] = await laad(bytes); + assert.equal(terug.icon, 'tag'); + assert.equal(terug.attachment.name, 'wapening.xlsx'); + assert.equal(lees(bijlageBytes(terug.attachment.key)), 'PK-xlsx-inhoud'); + assert.equal(terug.attachment.createdAt, '2026-08-15T10:00:00.000Z'); +}); + +test('a new attachment annotation is saved with its file and reopened', async () => { + const bron = await leegBlad(); + const nu = '2026-10-27T12:00:00.000Z'; + const ann = { + id: 'nieuw1', type: 'fileAttachment', page: 1, x: 20, y: 30, width: 24, height: 24, + text: 'model.ifc', color: '#2B579A', icon: 'paperclip', opacity: 1, author: 'Tester', + createdAt: nu, modifiedAt: nu, locked: false, printable: true, + attachment: { + key: bewaarBijlageInhoud({ bytes: tekst('ISO-10303-21;\nDATA;') }), name: 'model.ifc', description: 'IFC-model', + mime: 'application/x-step', size: 19, createdAt: nu, modifiedAt: nu, relationship: null, pdfjsId: null, + }, + }; + const bytes = await opslaan(bron, [ann]); + + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const pdfjs = await getDocument({ data: bytes.slice(), isEvalSupported: false, verbosity: 0 }).promise; + try { + const [a] = await (await pdfjs.getPage(1)).getAnnotations(); + assert.equal(a.subtype, 'FileAttachment'); + assert.equal(a.file.filename, 'model.ifc'); + assert.equal(a.file.description, 'IFC-model'); + assert.equal(lees(await pdfjs.getAttachmentContent(a.fileId)), 'ISO-10303-21;\nDATA;'); + } finally { + await pdfjs.loadingTask.destroy(); + } + const [terug] = await laad(bytes); + assert.equal(terug.type, 'fileAttachment'); + assert.equal(terug.author, 'Tester'); + assert.deepEqual([terug.x, terug.y, terug.width, terug.height], [20, 30, 24, 24]); + assert.equal(terug.attachment.description, 'IFC-model'); + assert.equal(terug.attachment.size, 19); + assert.equal(lees(bijlageBytes(terug.attachment.key)), 'ISO-10303-21;\nDATA;'); +}); + +test('a deleted attachment annotation leaves no annotation and no file behind', async () => { + const bron = vreemdBlad(); + await laad(bron); + const bytes = await opslaan(bron, []); + const pdf = await PDFDocument.load(bytes); + assert.equal(bijlageAnnots(pdf).length, 0); + const doc = mupdf.Document.openDocument(bytes, 'application/pdf').asPDF(); + // Ook de popup die MuPDF bij de annotatie maakte, is weg. + assert.equal(doc.loadPage(0).getAnnotations().length, 0); + // Ook de ingesloten stroom is opgeruimd. + for (const [, obj] of pdf.context.enumerateIndirectObjects()) { + assert.notEqual(obj?.dict?.lookup?.(N('Type'))?.toString(), '/EmbeddedFile'); + } +}); + +test('without content in the store, the saver falls back to pdf.js and keeps the file', async () => { + const bron = vreemdBlad(); + const [ann] = await laad(bron); + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + const doc = await pdfjs.getDocument({ data: bron.slice(), isEvalSupported: false, verbosity: 0 }).promise; + const [a] = await (await doc.getPage(1)).getAnnotations(); + ann.attachment.key = null; + ann.attachment.pdfjsId = a.fileId; + let uit = null; + globalThis.__saverTest = { impl: { + getCachedPdfBytes: () => bron, + writeBinaryFile: (_p, b) => { uit = b; return true; }, + writeBinaryFileAtomic: (_p, b) => { uit = b; return true; }, + } }; + state.documents = [{ + id: 'terugval', filePath: 'C:/bijlage/bron.pdf', pdfDoc: doc, annotations: [ann], + pageRotations: {}, _annotationPagesReady: new Set([1]), textEdits: [], + }]; + state.activeDocumentIndex = 0; + assert.equal(await savePDF('C:/bijlage/uit.pdf', { zonderHandtekeningVraag: true }), true); + await doc.loadingTask.destroy(); + const [terug] = await laad(uit); + assert.equal(lees(bijlageBytes(terug.attachment.key)), 'PK-xlsx-inhoud'); + assert.ok(ann.attachment.key, 'the content is now in the store'); +}); + +/** + * Een blad van een ander programma waarvan de bestandsspecificatie meer + * draagt dan de app kent (/CI en een eigen sleutel), met een eigen /AP. + */ +async function blad_metExtraSleutels() { + const pdf = await PDFDocument.load(vreemdBlad()); + const [annot] = bijlageAnnots(pdf); + const fs = annot.lookup(N('FS')); + fs.set(N('CI'), pdf.context.obj({ Type: 'CollectionItem', Status: 'Approved' })); + fs.set(N('VendorKey'), pdf.context.obj(42)); + return pdf.save(); +} + +/** Laden, en dan de inhoud onleesbaar maken: niet in de store en geen pdf.js-terugval. */ +async function laadZonderInhoud(bron) { + const modellen = await laad(bron); + for (const m of modellen) { + m.attachment.key = null; + m.attachment.pdfjsId = null; + } + return modellen; +} + +function ingeslotenBestand(bytes) { + const doc = mupdf.Document.openDocument(bytes, 'application/pdf').asPDF(); + const [ma] = doc.loadPage(0).getAnnotations(); + return lees(doc.getEmbeddedFileContents(ma.getFileSpec()).asUint8Array()); +} + +test('unreadable content, not edited: the original annotation stays as it was', async () => { + const bron = await blad_metExtraSleutels(); + const voor = bijlageAnnots(await PDFDocument.load(bron))[0]; + const apVoor = voor.lookup(N('AP')).toString(); + const modellen = await laadZonderInhoud(bron); + const bytes = await opslaan(bron, modellen); + + const pdf = await PDFDocument.load(bytes); + const annots = bijlageAnnots(pdf); + assert.equal(annots.length, 1, 'no duplicate'); + const d = annots[0]; + const fs = d.lookup(N('FS')); + assert.ok(fs.lookup(N('EF')), 'the embedded file is still there'); + assert.equal(fs.lookup(N('VendorKey')).asNumber(), 42, 'unknown file specification keys are kept'); + assert.equal(fs.lookup(N('CI')).lookup(N('Status')).toString(), '/Approved'); + assert.equal(d.lookup(N('AP')).toString(), apVoor, 'the original appearance is kept'); + assert.equal(ingeslotenBestand(bytes), 'PK-xlsx-inhoud'); + // Ook de popup van het origineel blijft bij zijn annotatie. + const popups = pdf.getPage(0).node.Annots().asArray().map((r) => pdf.context.lookup(r)) + .filter((x) => x.lookup(N('Subtype'))?.toString() === '/Popup'); + const eigenRef = pdf.getPage(0).node.Annots().asArray().find((r) => pdf.context.lookup(r) === d); + assert.equal(popups.length, 1, 'the popup MuPDF made is kept'); + assert.equal(popups[0].get(N('Parent')), eigenRef); +}); + +test('unreadable content, edited: the new annotation keeps the original file specification', async () => { + const bron = await blad_metExtraSleutels(); + const modellen = await laadZonderInhoud(bron); + modellen[0].x += 100; + modellen[0].icon = 'tag'; + const bytes = await opslaan(bron, modellen); + + const pdf = await PDFDocument.load(bytes); + const annots = bijlageAnnots(pdf); + assert.equal(annots.length, 1); + const d = annots[0]; + assert.deepEqual(d.lookup(N('Rect')).asArray().map((n) => n.asNumber()), [150, 80, 170, 100]); + assert.equal(d.lookup(N('Name')).toString(), '/Tag'); + const fs = d.lookup(N('FS')); + assert.ok(fs.lookup(N('EF')), 'the embedded file is still there'); + assert.equal(fs.lookup(N('VendorKey')).asNumber(), 42); + assert.equal(ingeslotenBestand(bytes), 'PK-xlsx-inhoud'); +}); + +test('a copy of an unedited attachment with unreadable content does not take the original twice', async () => { + const bron = await blad_metExtraSleutels(); + const [ann] = await laadZonderInhoud(bron); + const kopie = { ...cloneAnnotation(ann), id: 'kopie' }; + const bytes = await opslaan(bron, [ann, kopie]); + const pdf = await PDFDocument.load(bytes); + const annots = bijlageAnnots(pdf); + assert.equal(annots.length, 2); + assert.notEqual(annots[0], annots[1]); + for (const d of annots) assert.ok(d.lookup(N('FS')).lookup(N('EF')), 'both keep the embedded file'); +}); diff --git a/open-pdf-studio/js/pdf/bijlagen.js b/open-pdf-studio/js/pdf/bijlagen.js index d48dcc82f..ba143b7d4 100644 --- a/open-pdf-studio/js/pdf/bijlagen.js +++ b/open-pdf-studio/js/pdf/bijlagen.js @@ -11,7 +11,10 @@ // // Puur pdf-lib, geen app-imports: zo is het in node te testen. -import { PDFName, PDFDict, PDFArray, PDFStream, PDFRef, PDFString, PDFHexString } from 'pdf-lib'; +import { + PDFName, PDFDict, PDFArray, PDFStream, PDFRawStream, PDFRef, PDFString, PDFHexString, PDFNumber, + decodePDFRawStream, +} from 'pdf-lib'; /** @typedef {{ naam: string, bytes: Uint8Array, mime?: string, beschrijving?: string, * aangemaakt?: string, gewijzigd?: string }} Bijlage */ @@ -73,6 +76,48 @@ export function zichtbareBijlagen(basis, wijzigingen) { ]; } +// Bestanden die het systeem uitvoert, installeert, koppelt of als snelkoppeling +// volgt in plaats van ze alleen te tonen: eerst vragen (ui/panels/attachments.js). +const UITVOERBAAR = new Set([ + // Windows: programma's, installatiepakketten, scripts, snelkoppelingen + 'exe', 'com', 'bat', 'cmd', 'msi', 'msp', 'mst', 'msc', 'msix', 'msixbundle', 'appx', 'appxbundle', + 'scr', 'pif', 'cpl', 'dll', 'ocx', 'sys', 'drv', 'gadget', 'application', 'appref-ms', 'xbap', 'xll', + 'ps1', 'ps1xml', 'ps2', 'ps2xml', 'psc1', 'psc2', 'psd1', 'psm1', 'msh', 'msh1', 'msh2', 'mshxml', + 'vb', 'vbs', 'vbe', 'js', 'jse', 'ws', 'wsf', 'wsc', 'wsh', 'hta', 'chm', 'hlp', 'inf', 'ins', 'isp', + 'reg', 'lnk', 'url', 'scf', 'website', 'settingcontent-ms', 'library-ms', 'search-ms', 'searchconnector-ms', + 'iso', 'img', 'vhd', 'vhdx', 'diagcab', 'jnlp', 'jar', + // Scripttalen die vaak aan een interpreter gekoppeld zijn + 'py', 'pyw', 'pyc', 'pl', 'rb', 'php', 'lua', 'tcl', + // Linux en macOS + 'sh', 'bash', 'zsh', 'csh', 'ksh', 'fish', 'run', 'bin', 'desktop', 'appimage', 'deb', 'rpm', 'snap', 'flatpakref', + 'app', 'command', 'tool', 'pkg', 'mpkg', 'dmg', 'scpt', 'scptd', 'applescript', 'workflow', 'action', + 'terminal', 'webloc', 'inetloc', 'fileloc', +]); + +/** + * Moet er gevraagd worden voor het openen van dit bestand? Windows negeert + * punten en spaties aan het eind van een naam ("x.exe. " is x.exe), dus die + * tellen niet mee. + */ +export function isUitvoerbaarBestand(filename) { + const naam = String(filename || '').split(/[\\/]/).pop().replace(/[\s.]+$/, ''); + const punt = naam.lastIndexOf('.'); + return punt >= 0 && UITVOERBAAR.has(naam.slice(punt + 1).toLowerCase()); +} + +/** + * Een bestandsnaam uit een PDF als naam in de tijdelijke map: geen pad, geen + * tekens die Windows niet toestaat (ook niet ':' van een alternatieve + * gegevensstroom), geen punten of spaties aan het eind. + */ +export function tijdelijkeBijlageNaam(filename) { + const naam = String(filename || '').split(/[\\/]/).pop() + // eslint-disable-next-line no-control-regex + .replace(/[<>:"|?*\u0000-\u001f]/g, '_') + .replace(/[\s.]+$/, ''); + return naam && !/^\.+$/.test(naam) ? naam : 'attachment'; +} + function tekstVan(obj) { if (obj instanceof PDFHexString || obj instanceof PDFString) { try { return obj.decodeText(); } catch { return null; } @@ -113,27 +158,146 @@ export function leesBijlageNamen(pdfDoc) { return ef ? bladen(pdfDoc.context, ef).map(([sleutel]) => tekstVan(sleutel)).filter((n) => n !== null) : []; } -function maakBestandsspecificatie(pdfDoc, bijlage) { +// /F is een bytestring (de bestandsnaam voor oudere lezers): bij een naam in +// gewone ASCII die naam zelf, anders UTF-16 zoals /UF. +function bestandsnaamBytes(naam) { + if (/^[\x20-\x7e]*$/.test(naam)) { + return PDFHexString.of([...naam].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join('')); + } + return PDFHexString.fromText(naam); +} + +/** + * Een bestandsspecificatie met ingesloten bestand (ISO 32000 7.11.3/7.11.4): + * /Type /Filespec, /F en /UF, /EF met de stroom (/Type /EmbeddedFile, + * /Subtype het mime-type, /Params met /Size en de datums), /Desc en + * /AFRelationship. Met `bijlage.ruw` (een stroom die bij het laden niet te + * decoderen was) gaan de bytes met hun eigen filter ongewijzigd terug. + * @returns {import('pdf-lib').PDFRef} + */ +export function maakBestandsspecificatie(pdfDoc, bijlage) { const { context } = pdfDoc; - const params = { Size: bijlage.bytes.length }; const datum = (iso) => { const d = iso ? new Date(iso) : null; return d && !Number.isNaN(d.getTime()) ? PDFString.fromDate(d) : undefined; }; + const grootte = bijlage.ruw ? (bijlage.grootte ?? undefined) : bijlage.bytes.length; + const params = { Size: grootte }; params.CreationDate = datum(bijlage.aangemaakt); params.ModDate = datum(bijlage.gewijzigd); - const stroom = context.register(context.flateStream(bijlage.bytes, { + const kop = { Type: 'EmbeddedFile', + // /Subtype is een naam: een mime-type als 'text/plain' wordt #2f-gecodeerd. Subtype: bijlage.mime || undefined, Params: params, - })); + }; + let stroom; + if (bijlage.ruw) { + const r = bijlage.ruw; + const dict = context.obj({ ...kop, Length: r.bytes.length }); + if (r.filter) dict.set(PDFName.of('Filter'), context.obj(r.filter)); + if (r.decodeParms) dict.set(PDFName.of('DecodeParms'), context.obj(r.decodeParms)); + stroom = context.register(PDFRawStream.of(dict, r.bytes)); + } else { + stroom = context.register(context.flateStream(bijlage.bytes, kop)); + } return context.register(context.obj({ Type: 'Filespec', - F: PDFHexString.fromText(bijlage.naam), + F: bestandsnaamBytes(bijlage.naam), UF: PDFHexString.fromText(bijlage.naam), EF: { F: stroom, UF: stroom }, Desc: PDFHexString.fromText(bijlage.beschrijving || bijlage.naam), - AFRelationship: 'Unspecified', + AFRelationship: bijlage.relatie || 'Unspecified', })); } +function pdfDatumNaarIso(tekst) { + const m = /^D?:?(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?([Zz+\-])?(\d{2})?'?(\d{2})?/.exec(String(tekst || '').trim()); + if (!m) return null; + const [, j, mo = '01', d = '01', u = '00', mi = '00', s = '00', teken, ou, om] = m; + let iso = `${j}-${mo}-${d}T${u}:${mi}:${s}`; + iso += (teken === '+' || teken === '-') ? `${teken}${ou || '00'}:${om || '00'}` : 'Z'; + const dt = new Date(iso); + return Number.isNaN(dt.getTime()) ? null : dt.toISOString(); +} + +function naamWaarde(obj) { + return obj instanceof PDFName ? obj.decodeText() : null; +} + +/** + * Leest een bestandsspecificatie, ook een van een ander programma: naam (/UF, + * /F, /Unix, /Mac, /DOS), beschrijving, mime-type, grootte en datums, en de + * inhoud uit /EF. Een stroom die pdf-lib niet kan decoderen komt mee als + * `ruw` (bytes, /Filter, /DecodeParms als JSON-vrije pdf-lib-objecten + * omgezet naar namen en getallen), zodat opslaan hem niet kwijtraakt. + * Een /FS die een tekenreeks is (een extern bestand) geeft alleen de naam. + * @returns {null|{naam:string, beschrijving:string, mime:string|null, grootte:number|null, + * aangemaakt:string|null, gewijzigd:string|null, relatie:string|null, + * bytes?:Uint8Array, ruw?:{bytes:Uint8Array, filter:any, decodeParms:any}, extern?:boolean}} + */ +export function leesBestandsspecificatie(context, fsObj) { + const fs = fsObj instanceof PDFRef ? context.lookup(fsObj) : fsObj; + const tekst = tekstVan(fs); + if (tekst !== null) { + return { naam: tekst.split(/[\\/]/).pop() || tekst, beschrijving: '', mime: null, grootte: null, aangemaakt: null, gewijzigd: null, relatie: null, extern: true }; + } + if (!(fs instanceof PDFDict)) return null; + const veld = (d, k) => (d instanceof PDFDict ? d.lookup(PDFName.of(k)) : undefined); + let naam = null; + for (const k of ['UF', 'F', 'Unix', 'Mac', 'DOS']) { + naam = tekstVan(veld(fs, k)); + if (naam) break; + } + naam = (naam || '').replace(/\\/g, '/').split('/').pop() || 'bijlage'; + const uit = { + naam, + beschrijving: tekstVan(veld(fs, 'Desc')) || '', + mime: null, grootte: null, aangemaakt: null, gewijzigd: null, + relatie: naamWaarde(veld(fs, 'AFRelationship')), + }; + const ef = veld(fs, 'EF'); + let stroom = null; + for (const k of ['UF', 'F', 'Unix', 'Mac', 'DOS']) { + const s = veld(ef, k); + if (s instanceof PDFStream) { stroom = s; break; } + } + if (!stroom) return { ...uit, extern: true }; + const kop = stroom.dict; + const sub = naamWaarde(kop.lookup(PDFName.of('Subtype'))); + if (sub) uit.mime = sub; + const params = kop.lookup(PDFName.of('Params')); + if (params instanceof PDFDict) { + const size = params.lookup(PDFName.of('Size')); + if (size instanceof PDFNumber) uit.grootte = size.asNumber(); + const cd = params.lookup(PDFName.of('CreationDate')); + const md = params.lookup(PDFName.of('ModDate')); + uit.aangemaakt = cd instanceof PDFString || cd instanceof PDFHexString ? pdfDatumNaarIso(cd.decodeText()) : null; + uit.gewijzigd = md instanceof PDFString || md instanceof PDFHexString ? pdfDatumNaarIso(md.decodeText()) : null; + } + try { + const bytes = stroom instanceof PDFRawStream ? decodePDFRawStream(stroom).decode() : stroom.getContents(); + uit.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (uit.grootte === null) uit.grootte = uit.bytes.length; + } catch { + const ruwWaarde = (o) => { + const v = o instanceof PDFRef ? context.lookup(o) : o; + if (v instanceof PDFName) return v.decodeText(); + if (v instanceof PDFNumber) return v.asNumber(); + if (v instanceof PDFArray) return v.asArray().map(ruwWaarde); + if (v instanceof PDFDict) { + const d = {}; + for (const [k, w] of v.entries()) d[k.decodeText()] = ruwWaarde(w); + return d; + } + return null; + }; + uit.ruw = { + bytes: stroom.getContents(), + filter: ruwWaarde(kop.get(PDFName.of('Filter'))), + decodeParms: ruwWaarde(kop.get(PDFName.of('DecodeParms'))), + }; + } + return uit; +} + // Verwijst een object (diep, binnen directe woordenboeken en arrays) naar `ref`? function verwijstNaar(obj, ref, diepte = 0) { if (obj === ref) return true; diff --git a/open-pdf-studio/js/pdf/bijlagen.test.mjs b/open-pdf-studio/js/pdf/bijlagen.test.mjs index ebb1f05b4..3e549c72a 100644 --- a/open-pdf-studio/js/pdf/bijlagen.test.mjs +++ b/open-pdf-studio/js/pdf/bijlagen.test.mjs @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import { PDFDocument, PDFName, PDFHexString, PDFDict } from 'pdf-lib'; import { pasBijlagenToe, leesBijlageNamen, vrijeNaam, metToegevoegd, metVerwijderd, zichtbareBijlagen, wijzigingenVan, + isUitvoerbaarBestand, tijdelijkeBijlageNaam, } from './bijlagen.js'; const tekst = (s) => new TextEncoder().encode(s); @@ -133,3 +134,29 @@ test('the panel model: unique names, replace, remove and the visible list', () = assert.deepEqual(doc.bijlagenWijzigingen.verwijderd, ['x']); assert.deepEqual(wijzigingenVan({}), leeg); }); + +test('opening asks first for files the system runs, installs or follows', () => { + for (const naam of [ + 'setup.exe', 'run.BAT', 'x.msc', 'help.chm', 'link.url', 'disk.iso', 'snel.lnk', 'scherm.scr', 'app.hta', + 'paneel.cpl', 'tool.jar', 'script.ps1', 'script.vbs', 'script.wsf', 'x.scf', 'x.application', 'x.appref-ms', + 'x.vb', 'x.ws', 'x.inf', 'x.vhd', 'x.msix', 'x.settingcontent-ms', 'x.py', 'x.sh', 'x.desktop', 'x.AppImage', + 'x.dmg', 'x.pkg', 'x.command', 'x.workflow', + // Windows negeert punten en spaties aan het eind. + 'setup.exe.', 'setup.exe ', 'map/sub\\setup.exe', + ]) assert.equal(isUitvoerbaarBestand(naam), true, naam); + for (const naam of ['tekening.pdf', 'model.ifc', 'staat.xlsx', 'foto.jpg', 'exe', 'readme', '', null, 'notes.txt', 'archief.zip']) { + assert.equal(isUitvoerbaarBestand(naam), false, String(naam)); + } +}); + +test('a file name from a PDF becomes a safe temp file name', () => { + assert.equal(tijdelijkeBijlageNaam('..\\..\\Windows\\evil.exe'), 'evil.exe'); + assert.equal(tijdelijkeBijlageNaam('/etc/passwd'), 'passwd'); + assert.equal(tijdelijkeBijlageNaam('notes.txt:hidden.exe'), 'notes.txt_hidden.exe'); + assert.equal(isUitvoerbaarBestand(tijdelijkeBijlageNaam('notes.txt:hidden.exe')), true); + assert.equal(tijdelijkeBijlageNaam('a|c?.pdf'), 'a_b__c_.pdf'); + assert.equal(tijdelijkeBijlageNaam('setup.exe. . '), 'setup.exe'); + assert.equal(tijdelijkeBijlageNaam('..'), 'attachment'); + assert.equal(tijdelijkeBijlageNaam(''), 'attachment'); + assert.equal(tijdelijkeBijlageNaam(undefined), 'attachment'); +}); diff --git a/open-pdf-studio/js/pdf/document-release.js b/open-pdf-studio/js/pdf/document-release.js index be6450479..338c47d1d 100644 --- a/open-pdf-studio/js/pdf/document-release.js +++ b/open-pdf-studio/js/pdf/document-release.js @@ -228,10 +228,20 @@ export async function ruimWerkbestandenOp(doc, paden, middelen = schijfMiddelen( /** * Voert het vrijgaveplan uit. Fouten in één stap houden de andere niet * tegen: het document is al dicht, dit is opruimen. + * @param {object} gesloten + * @param {object[]} overgebleven + * @param {{klembord?: unknown[]}} [opties] klembord: gekopieerde annotaties, + * die de inhoud van hun bijlage nog nodig hebben */ -export async function geefDocumentVrij(gesloten, overgebleven) { +export async function geefDocumentVrij(gesloten, overgebleven, { klembord = [] } = {}) { const plan = vrijgaveplan(gesloten, overgebleven); + // De bestanden achter bijlage-annotaties (#376) van dit document. + try { + const { geefBijlagenVrij } = await import('../annotations/bijlage-store.js'); + geefBijlagenVrij(gesloten, overgebleven, klembord); + } catch (e) { console.warn('[release] bijlage-inhoud vrijgeven:', e); } + if (plan.pdfjsVrijgeven) { try { await (gesloten.pdfDoc.loadingTask || gesloten.pdfDoc).destroy(); } catch (e) { console.warn('[release] PDF.js afsluiten:', e); } } diff --git a/open-pdf-studio/js/pdf/loader.js b/open-pdf-studio/js/pdf/loader.js index af0bf6f78..09fe3d0af 100644 --- a/open-pdf-studio/js/pdf/loader.js +++ b/open-pdf-studio/js/pdf/loader.js @@ -1138,7 +1138,7 @@ async function loadAnnotationsForSinglePage(doc, pageNum, waitForColors = false) const stampAnnots = annotations.filter(a => a.subtype === 'Stamp'); const hasSquareAnnotations = annotations.some(a => a.subtype === 'Square'); - const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret'].includes(a.subtype)); + const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret', 'FileAttachment'].includes(a.subtype)); let stampImageMap = null; let annotColorMap = null; @@ -1293,7 +1293,7 @@ export async function loadExistingAnnotations(doc) { const stampAnnots = annotations.filter(a => a.subtype === 'Stamp'); const hasSquareAnnotations = annotations.some(a => a.subtype === 'Square'); - const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret'].includes(a.subtype)); + const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret', 'FileAttachment'].includes(a.subtype)); let stampImageMap = null; let annotColorMap = null; @@ -1344,7 +1344,7 @@ export async function loadExistingAnnotations(doc) { const stampAnnots = annotations.filter(a => a.subtype === 'Stamp'); const hasSquareAnnotations = annotations.some(a => a.subtype === 'Square'); - const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret'].includes(a.subtype)); + const needsExtraData = annotations.some(a => ['FreeText', 'Square', 'Circle', 'Line', 'PolyLine', 'Polygon', 'Ink', 'Text', 'Highlight', 'Underline', 'StrikeOut', 'Squiggly', 'Stamp', 'Caret', 'FileAttachment'].includes(a.subtype)); let stampImageMap = null; let annotColorMap = null; diff --git a/open-pdf-studio/js/pdf/loader/annotatie-opmerking.js b/open-pdf-studio/js/pdf/loader/annotatie-opmerking.js index 34562e9ed..9fb0af6bc 100644 --- a/open-pdf-studio/js/pdf/loader/annotatie-opmerking.js +++ b/open-pdf-studio/js/pdf/loader/annotatie-opmerking.js @@ -15,7 +15,7 @@ /** Soorten waarvan de saver /Contents uit een eigen veld schrijft. */ export const SOORTEN_MET_EIGEN_TEKST = new Set([ - 'comment', // notitietekst + 'comment', 'fileAttachment', // notitie- en bijlagetekst 'caret', // ingevoegde tekst 'textbox', 'callout', // de tekst zelf 'stamp', 'vectorSnippet', 'stavenreeks', // stempel-/knipsellabel diff --git a/open-pdf-studio/js/pdf/loader/annotation-converter.js b/open-pdf-studio/js/pdf/loader/annotation-converter.js index 3389fb3af..dfd0af536 100644 --- a/open-pdf-studio/js/pdf/loader/annotation-converter.js +++ b/open-pdf-studio/js/pdf/loader/annotation-converter.js @@ -42,6 +42,8 @@ import { inzetUitDsMarge, tolerantieVoorDsInzet } from '../../annotations/render import { caretPropsFromPdf, textEditPropsFromPdf } from './correction-load.js'; import { algemeneEigenschappen } from './annotatie-algemeen.js'; import { ontdraaiVorm } from '../saver/vorm-draaiing.js'; +import { bijlageIcoonUitPdf } from '../../annotations/bijlage-iconen.js'; +import { bijlageBron } from '../bijlage-annotatie.js'; import { stempelGeometrie, kiesStempelWeergave, oudeStempelDraaiing } from './stempel-geometrie.js'; /** @@ -62,7 +64,15 @@ export async function convertPdfAnnotation(annot, pageNum, viewport, stampImageM } // De laag komt uit het bestand (/OC, zie color-extraction.js), nooit uit de // huidige laag die createAnnotation voor nieuwe markeringen invult (#468). - return zetLaagUitBestand(omgezet, extraVoorAnnotatie(annotColorMap, annot)?.layer); + const metLaag = zetLaagUitBestand(omgezet, extraVoorAnnotatie(annotColorMap, annot)?.layer); + // Een bijlage-annotatie (#376) onthoudt waar ze vandaan kwam en hoe ze + // geladen werd: is haar inhoud bij het opslaan niet te lezen, dan houdt de + // saver het origineel (pdf/bijlage-annotatie.js). + if (metLaag?.type === 'fileAttachment' && metLaag.attachment) { + const fsRef = extraVoorAnnotatie(annotColorMap, annot)?.bijlage?.bestand?.fsRef || null; + metLaag.attachment.bron = bijlageBron(metLaag, annot.id, fsRef); + } + return metLaag; } /** @@ -1198,6 +1208,39 @@ async function converteerPdfAnnotatie(annot, pageNum, viewport, stampImageMap, a }); } + case 'FileAttachment': { + // Bijlage-annotatie (#376): pictogram met een ingesloten bestand. Het + // bestand zelf las color-extraction.js met pdf-lib (extraColors.bijlage). + const vak = convertRect(rect); + const bijlage = extraColors.bijlage || null; + const bestand = bijlage?.bestand || {}; + return createAnnotation({ + ...baseProps, + type: 'fileAttachment', + x: vak.x, + y: vak.y, + width: vak.width || 24, + height: vak.height || 24, + text: (annot.contentsObj && annot.contentsObj.str) || annot.contents || '', + color: colorArrayToHex(annot.color, '#2B579A'), + icon: bijlage?.icoon || bijlageIcoonUitPdf(annot.name), + attachment: { + key: bestand.key || null, + name: bestand.naam || annot.file?.filename || 'attachment', + description: bestand.beschrijving || annot.file?.description || '', + mime: bestand.mime || null, + size: bestand.grootte ?? null, + createdAt: bestand.aangemaakt || null, + modifiedAt: bestand.gewijzigd || null, + relationship: bestand.relatie || null, + external: !!bestand.extern, + // Terugval als de inhoud (nog) niet via pdf-lib gelezen is: pdf.js + // levert hem op dit id (getAttachmentContent). + pdfjsId: annot.fileId || null, + }, + }); + } + case 'FreeText': { // Extract font size, font family, bold/italic, and text color let fontSize = 14; diff --git a/open-pdf-studio/js/pdf/loader/color-extraction.js b/open-pdf-studio/js/pdf/loader/color-extraction.js index 8f1ed49c0..4d2a5244b 100644 --- a/open-pdf-studio/js/pdf/loader/color-extraction.js +++ b/open-pdf-studio/js/pdf/loader/color-extraction.js @@ -1,5 +1,5 @@ import { parseEditorDom } from '../../text/editor-dom-parse.js'; -import { PDFName, PDFDict, PDFArray, PDFHexString, PDFString } from 'pdf-lib'; +import { PDFName, PDFDict, PDFArray, PDFHexString, PDFString, PDFRef } from 'pdf-lib'; import { pdfNum, pdfColorToHex, mapPdfFontName, inflateBytes, rcLettertype } from './pdf-helpers.js'; import { fillAlphaAtFirstFill } from './ap-fill-alpha.js'; import { leesKnipselBronnen, leesKnipselVelden } from './vector-snippet-load.js'; @@ -12,6 +12,33 @@ import { leesPlattegrondMeta } from './plattegrond-meta.js'; import { leesLijnSleutels } from './maatlijn-uit-bestand.js'; import { blendUitPdf } from '../../annotations/blend-modes.js'; import { leesStempelAp } from './stempel-ap.js'; +import { leesBijlageAnnotatie } from '../bijlage-annotatie.js'; +import { bewaarBijlageInhoud, bijlageItem } from '../../annotations/bijlage-store.js'; + +// Bijlage-annotaties (#376) die al gelezen zijn, op hun /FS-object: een pagina +// kan meer dan eens geëxtraheerd worden, de inhoud hoeft maar één keer in de +// store. +const _bijlagenGelezen = new WeakMap(); + +function bijlageUitAnnotatie(context, annotDict) { + const fsRaw = annotDict.get(PDFName.of('FS')); + const fsObj = context.lookup(fsRaw); + const eerder = fsObj && typeof fsObj === 'object' ? _bijlagenGelezen.get(fsObj) : null; + // Een sleutel waarvan de inhoud al is vrijgegeven (het document was dicht, + // bijlage-store.js) telt niet: dan opnieuw lezen. + if (eerder && (!eerder.bestand.key || bijlageItem(eerder.bestand.key))) return { ...eerder }; + const gelezen = leesBijlageAnnotatie(context, annotDict); + if (!gelezen) return null; + const { bytes, ruw, ...meta } = gelezen.bestand; + const key = (bytes || ruw) ? bewaarBijlageInhoud(bytes ? { bytes } : { ruw }) : null; + // Het /FS-object (pdf.js-vorm, `12R`): de saver vindt er het ingesloten + // bestand mee terug als de inhoud later niet te lezen is. + const fsRef = fsRaw instanceof PDFRef + ? `${fsRaw.objectNumber}R${fsRaw.generationNumber ? fsRaw.generationNumber : ''}` : null; + const uit = { icoon: gelezen.icoon, bestand: { ...meta, key, fsRef } }; + if (fsObj && typeof fsObj === 'object') _bijlagenGelezen.set(fsObj, uit); + return { ...uit }; +} // Documenten waarvan de knipsel-bronpagina's al in de store staan: dat // uitpakken gebeurt één keer per document, bij het eerste knipsel dat we zien. @@ -322,6 +349,16 @@ export async function extractAnnotationColors(pageNum, pdfDoc) { } } + // Bijlage-annotatie (#376): het bestand achter het pictogram. + if (subtypeName === '/FileAttachment') { + try { + const bijlage = bijlageUitAnnotatie(context, annotDict); + if (bijlage) colors.bijlage = bijlage; + } catch (e) { + console.warn('[loader] file attachment could not be read:', e?.message || e); + } + } + // Read stamp-specific entries (PDF.js doesn't expose /Name for stamps) if (subtypeName === '/Stamp') { const nameRaw = annotDict.get(PDFName.of('Name')); diff --git a/open-pdf-studio/js/pdf/saver.js b/open-pdf-studio/js/pdf/saver.js index 5314c2ab1..805e5cb67 100644 --- a/open-pdf-studio/js/pdf/saver.js +++ b/open-pdf-studio/js/pdf/saver.js @@ -56,6 +56,8 @@ import { pasBijlagenToe } from './bijlagen.js'; import { schrijfAnnotatieLagen, ocVoorAnnotatie, laagstandZonderEigenLagen } from './saver/annotatie-lagen.js'; import { schrijfStandaardLaagstand } from './lagen-boom.js'; import { standaardOmTeBewaren, naOpslaan as lagenNaOpslaan } from './lagen-schakelen.js'; +import { bouwBijlageAnnotatie, origineleBijlage, bijlageBewerkt } from './bijlage-annotatie.js'; +import { bijlageInhoud } from '../annotations/bijlage-store.js'; import { layersForSave, currentLayerId } from '../annotations/annotatie-lagen.js'; import { vormVan, vakPadPdf } from '../annotations/rendering/textbox-vorm.js'; import { heeftNietWinAnsi, pdfTextString, toWinAnsiText, winAnsiLiteral, asciiPdfName } from './saver/pdf-text.js'; @@ -532,7 +534,7 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom const handledSubtypes = new Set([ '/Highlight', '/Underline', '/StrikeOut', '/Squiggly', '/Square', '/Circle', '/Line', '/Ink', '/PolyLine', '/Polygon', - '/Text', '/FreeText', '/Stamp', '/Caret' + '/Text', '/FreeText', '/Stamp', '/Caret', '/FileAttachment' ]); let annotsArray = []; const verwijderdeRefs = []; // weggehaald en uit het model herschreven @@ -584,6 +586,8 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom const dictById = new Map(); // /NM per pagina uniek: een kopie draagt de naam van het origineel mee. const gebruikteNm = new Set(); + // Bijlage-annotaties die ongemoeid blijven (#376, zie 'fileAttachment'). + const behoudenBijlagen = new Set(); // Add our annotations for (const annRaw of pageAnnotations) { @@ -610,6 +614,8 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom const borderWidth = ann.lineWidth ?? 2; let annotDict; + // Een origineel dat ongewijzigd blijft staan in plaats van een nieuwe dict. + let behoudenRef = null; switch (ann.type) { case 'highlight': @@ -1780,6 +1786,49 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom break; } + case 'fileAttachment': { + // Bijlage-annotatie (#376): pictogram met ingesloten bestand. + const faW = ann.width || 24; + const faH = ann.height || 24; + const faInhoud = await bijlageInhoud(annRaw, activeDoc); + const faBestand = annRaw.attachment || {}; + // Inhoud niet te lezen: het ingesloten bestand mag niet stil + // verdwijnen. Onbewerkt blijft het origineel staan zoals het was + // (met /FS, /AP en sleutels die de app niet kent); bewerkt krijgt + // de nieuwe annotatie de oorspronkelijke bestandsspecificatie. + const faOrigineel = faInhoud ? null : origineleBijlage(context, verwijderdeRefs, annRaw); + if (faOrigineel?.ref && !alleenAnnotaties && !bijlageBewerkt(annRaw) + && !behoudenBijlagen.has(faOrigineel.ref)) { + behoudenRef = faOrigineel.ref; + behoudenBijlagen.add(behoudenRef); + break; + } + annotDict = bouwBijlageAnnotatie(pdfDocLib, { + rect: [convertX(ann.x), convertY(ann.y + faH), convertX(ann.x + faW), convertY(ann.y)], + icoon: ann.icon, + kleur: ann.color, + kleurArray: hexToColorArray(ann.color || '#2B579A'), + opacity, + tekst: ann.text || '', + auteur: pdfTextString(ann.author || 'User'), + flags: computeAnnotFlags(ann), + tekstObj: pdfTextString, + bestand: { + naam: faBestand.name || 'attachment', + beschrijving: faBestand.description || ann.text || '', + mime: faBestand.mime || undefined, + aangemaakt: faBestand.createdAt || undefined, + gewijzigd: faBestand.modifiedAt || undefined, + relatie: faBestand.relationship || undefined, + grootte: faBestand.size ?? undefined, + ...(faInhoud?.bytes ? { bytes: faInhoud.bytes } : faInhoud?.ruw ? { ruw: faInhoud.ruw } : {}), + }, + bestaandeFs: faOrigineel?.fs, + }); + if (!faInhoud && !faOrigineel && annotDict) console.warn(`[saver] file attachment "${faBestand.name}" has no content; written without /EF`); + break; + } + case 'stamp': { // Stamp annotation — with or without embedded image const x1 = convertX(ann.x); @@ -3056,6 +3105,16 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom // Add annotation to page let parentAnnotRef = null; + if (behoudenRef) { + // Het origineel gaat terug de pagina op; antwoorden en koppelingen + // hieronder wijzen ernaar zoals naar een nieuwe annotatie. + parentAnnotRef = behoudenRef; + annotsArray.push(behoudenRef); + verwijderdeRefs.splice(verwijderdeRefs.indexOf(behoudenRef), 1); + if (annRaw.nm != null) gebruikteNm.add(String(annRaw.nm)); + refById.set(annRaw.id, behoudenRef); + dictById.set(annRaw.id, context.lookup(behoudenRef)); + } if (annotDict) { // Vul-alfa bewaren (zie fillOpacity hierboven) — één plek voor elke // annotatiesoort, vlak voordat de dict de pagina in gaat. @@ -3102,7 +3161,8 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom // /Text met /IRT naar deze annotatie (PDF-spec), zodat ze ook in andere // programma's bij de annotatie horen en bij heropenen terugkomen. if (parentAnnotRef) { - for (const antwoord of antwoordAnnotaties(context, ann, parentAnnotRef, annotDict, gebruikteNm)) { + const ouderDict = annotDict || (behoudenRef ? context.lookup(behoudenRef) : null); + for (const antwoord of antwoordAnnotaties(context, ann, parentAnnotRef, ouderDict, gebruikteNm)) { annotsArray.push(context.register(antwoord)); } } @@ -3179,7 +3239,14 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom applyGroupLinks(context, plan, refById, dictById, annotsArray); // Overige koppelingen naar een andere annotatie (#619): /IRT en /RT. schrijfKoppelingen(pageAnnotations, refById, dictById); - annotsArray = dropOrphanPopups(context, annotsArray, correctionRefKeys(context, verwijderdeRefs)); + // Ook de popup van een herschreven bijlage-annotatie (#376) valt weg: via + // haar /Parent hield die de oude annotatie, en daarmee een kopie van het + // ingesloten bestand, bij elke save in het bestand. + const popupOuders = correctionRefKeys(context, verwijderdeRefs); + for (const ref of verwijderdeRefs) { + if (context.lookup(ref)?.get?.(PDFName.of('Subtype'))?.toString() === '/FileAttachment') popupOuders.add(ref.toString()); + } + annotsArray = dropOrphanPopups(context, annotsArray, popupOuders); // Set the updated annotations array page.node.set(PDFName.of('Annots'), context.obj(annotsArray)); diff --git a/open-pdf-studio/js/pdf/saver/ruwe-sleutels.js b/open-pdf-studio/js/pdf/saver/ruwe-sleutels.js index d50fbc46e..c766e76ee 100644 --- a/open-pdf-studio/js/pdf/saver/ruwe-sleutels.js +++ b/open-pdf-studio/js/pdf/saver/ruwe-sleutels.js @@ -62,6 +62,13 @@ export const EIGEN_SLEUTELS = new Set([ 'Rotation', ]); +/** + * Per soort de sleutels die het model bezit. Een bijlage-annotatie (#376) + * heeft haar bestand in het model (bijlage-store): /FS en /AF schrijft de + * saver daar opnieuw uit. + */ +const EIGEN_PER_SOORT = { '/FileAttachment': new Set(['FS', 'AF']) }; + /** Sleutels die vorm beschrijven; zie de kop van dit bestand. */ export const GEOMETRIE_SLEUTELS = new Set([ 'Path', 'PathPressureList', 'PressureList', 'Curves', 'SO', 'CLEx', 'Segments', @@ -168,9 +175,11 @@ export function leesRuweSleutels(context, dict) { if (!(dict instanceof PDFDict)) return undefined; const uit = {}; const staat = { knopen: 0, bytes: 0 }; + const eigenSoort = EIGEN_PER_SOORT[dict.get(PDFName.of('Subtype'))?.toString() || '']; for (const [k, v] of dict.entries()) { const sleutel = k.decodeText(); if (sleutel.startsWith('OPS_') || (EIGEN_SLEUTELS.has(sleutel) && sleutel !== 'IT')) continue; + if (eigenSoort?.has(sleutel)) continue; try { uit[sleutel] = codeer(context, v, staat, []); } catch (e) { diff --git a/open-pdf-studio/js/solid/components/properties-panel/AppearanceSection.jsx b/open-pdf-studio/js/solid/components/properties-panel/AppearanceSection.jsx index eb9381cbf..f9b8fb542 100644 --- a/open-pdf-studio/js/solid/components/properties-panel/AppearanceSection.jsx +++ b/open-pdf-studio/js/solid/components/properties-panel/AppearanceSection.jsx @@ -94,17 +94,25 @@ export default function AppearanceSection() { - - - - - - - - - - - + + + + + + + + + + + + + }> + {/* Bijlage-annotatie (#376): de vier /Name-waarden van de specificatie. */} + + + + + diff --git a/open-pdf-studio/js/solid/components/properties-panel/ContentSection.jsx b/open-pdf-studio/js/solid/components/properties-panel/ContentSection.jsx index c515c21b3..f787940af 100644 --- a/open-pdf-studio/js/solid/components/properties-panel/ContentSection.jsx +++ b/open-pdf-studio/js/solid/components/properties-panel/ContentSection.jsx @@ -1,8 +1,24 @@ import { Show } from 'solid-js'; +import { getActiveDocument } from '../../../core/state.js'; import { annotProps, sectionVis, updateAnnotProp } from '../../stores/propertiesStore.js'; import CollapsibleSection from './CollapsibleSection.jsx'; import { useTranslation } from '../../../i18n/useTranslation.js'; +function grootteTekst(bytes) { + if (typeof bytes !== 'number' || !(bytes >= 0)) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// Openen en opslaan werken op de geselecteerde bijlage-annotatie. +function metBijlage(actie) { + const ann = getActiveDocument()?.selectedAnnotation; + if (ann?.type !== 'fileAttachment') return; + import('../../../ui/bijlage-annotaties.js').then((m) => m[actie](ann)) + .catch((e) => console.error('[properties] attachment action failed', e)); +} + export default function ContentSection() { const { t } = useTranslation('properties'); const isLocked = () => annotProps.locked; @@ -10,6 +26,23 @@ export default function ContentSection() { return ( + {/* Bijlage-annotatie (#376): het ingesloten bestand. */} + +
+ + +
+
+ + +
+
+
+ + +
+
+
diff --git a/open-pdf-studio/js/solid/components/ribbon/CommentTab.jsx b/open-pdf-studio/js/solid/components/ribbon/CommentTab.jsx index 77a829793..8ec15177a 100644 --- a/open-pdf-studio/js/solid/components/ribbon/CommentTab.jsx +++ b/open-pdf-studio/js/solid/components/ribbon/CommentTab.jsx @@ -16,7 +16,7 @@ import { redrawAnnotations, redrawContinuous } from '../../../annotations/render import { highlightIcon, freehandIcon, lineIcon, arrowIcon, polylineIcon, rectIcon, ellipseIcon, polygonIcon, cloudIcon, cloudPolylineIcon, - textAnnotIcon, textboxIcon, noteIcon, calloutIcon, + textAnnotIcon, textboxIcon, noteIcon, fileAttachmentIcon, calloutIcon, stampIcon, signatureIcon, redactionIcon, applyRedactionsIcon, clearPageIcon, clearAllIcon, flattenAllIcon, unflattenAllIcon, @@ -64,6 +64,8 @@ export function CommentGroups() { disabled={noPdf() || isPdfAReadOnly()} active={state.currentTool === 'callout'} onClick={() => setTool('callout')} /> setTool('comment')} /> + setTool('fileAttachment')} /> diff --git a/open-pdf-studio/js/solid/data/ribbonIcons.js b/open-pdf-studio/js/solid/data/ribbonIcons.js index 857fd8120..ebb670748 100644 --- a/open-pdf-studio/js/solid/data/ribbonIcons.js +++ b/open-pdf-studio/js/solid/data/ribbonIcons.js @@ -107,6 +107,8 @@ export const textAnnotIcon = `T`; +// Bijlage-annotatie (#376): paperclip. +export const fileAttachmentIcon = ``; export const noteIcon = ``; export const calloutIcon = ``; diff --git a/open-pdf-studio/js/solid/stores/propertiesStore.js b/open-pdf-studio/js/solid/stores/propertiesStore.js index b86d6bae0..0ea98ea31 100644 --- a/open-pdf-studio/js/solid/stores/propertiesStore.js +++ b/open-pdf-studio/js/solid/stores/propertiesStore.js @@ -114,6 +114,9 @@ const [annotProps, setAnnotProps] = createStore({ opacity: 100, fillOpacity: 100, icon: 'comment', + attachmentName: '', + attachmentSize: null, + attachmentMime: '', borderStyle: 'solid', boxShape: 'rect', cross: false, @@ -200,6 +203,7 @@ const [sectionVis, setSectionVis] = createStore({ customFields: false, // Sub-group visibility iconGroup: false, + attachmentGroup: false, fillColorGroup: false, fillNoneAllowed: true, strokeColorGroup: false, @@ -263,11 +267,11 @@ function computeSectionVisibility(type) { const isTextbox = ['textbox', 'callout'].includes(type); const isShape = ['line', 'arrow', 'box', 'circle', 'draw', 'textbox', 'callout'].includes(type); // Een invoegteken (#508) heeft de ingevoegde tekst als inhoud. - const isTextContent = type === 'text' || type === 'comment' || type === 'caret'; + const isTextContent = type === 'text' || type === 'comment' || type === 'caret' || type === 'fileAttachment'; const isImage = type === 'image'; const isLineOrArrow = type === 'arrow' || type === 'line'; const isTextMarkup = isTextAnchored({ type }); - const hideLineWidth = ['highlight', 'comment', 'image', 'textHighlight', 'caret'].includes(type); + const hideLineWidth = ['highlight', 'comment', 'fileAttachment', 'image', 'textHighlight', 'caret'].includes(type); // Schaalgebied (#611): eigen vulkleur en vul-doorzichtigheid; "geen" vulling // is daar vul-doorzichtigheid 0, want zonder vulkleur volgt de vulling de // randkleur (het oude uiterlijk). @@ -301,7 +305,9 @@ function computeSectionVisibility(type) { image: isImage, actions: true, customFields: hasCustomFields, - iconGroup: type === 'comment', + // Bijlage-annotatie (#376) heeft haar eigen pictogrammen (/Name). + iconGroup: type === 'comment' || type === 'fileAttachment', + attachmentGroup: type === 'fileAttachment', fillColorGroup: hasFillColor, fillNoneAllowed: !isScaleRegion, strokeColorGroup: isShape || type === 'measureDistance' || type === 'measureArea' || type === 'measurePerimeter' || type === 'filledArea', @@ -435,7 +441,10 @@ export function storeShowProperties(annotation) { : DRAFTING_RULE_TYPES.has(annotation.type) ? Math.round(effectiveDraftingLineWidth(annotation) * 100) / 100 : 3), opacity: annotation.opacity !== undefined ? Math.round(annotation.opacity * 100) : 100, - icon: annotation.icon || 'comment', + icon: annotation.icon || (annotation.type === 'fileAttachment' ? 'paperclip' : 'comment'), + attachmentName: annotation.attachment?.name || '', + attachmentSize: annotation.attachment?.size ?? null, + attachmentMime: annotation.attachment?.mime || '', borderStyle: annotation.borderStyle || 'solid', boxShape: annotation.boxShape || 'rect', cross: annotation.cross === true, @@ -806,7 +815,7 @@ export function storeShowMultiSelection(selected) { const fillColorTypes = new Set(['highlight', 'box', 'circle', 'polygon', 'cloud', 'textbox', 'callout', 'arrow', 'line', 'scaleRegion']); const strokeColorTypes = new Set(['line', 'arrow', 'box', 'circle', 'draw', 'textbox', 'callout', 'polygon', 'cloud']); const hideColorTypes = new Set(['line', 'arrow', 'box', 'circle', 'draw', 'highlight', 'image', 'textbox', 'callout', 'polygon', 'cloud']); - const hideLineWidthTypes = new Set(['highlight', 'comment', 'image', 'textHighlight', 'caret']); + const hideLineWidthTypes = new Set(['highlight', 'comment', 'fileAttachment', 'image', 'textHighlight', 'caret']); const borderStyleTypes = new Set(['textbox', 'callout', 'arrow', 'line', 'box', 'circle', 'polygon', 'cloud', 'draw', 'polyline', 'splineArrow']); const hatchPatternTypes = new Set(['box', 'circle', 'polygon', 'cloud', 'measureArea', 'filledArea']); const rotationTypes = new Set(['box', 'circle', 'polygon', 'cloud', 'highlight', 'redaction', 'comment', 'stamp', 'signature']); diff --git a/open-pdf-studio/js/tools/tool-dispatcher.js b/open-pdf-studio/js/tools/tool-dispatcher.js index a5d0bab21..39693df8b 100644 --- a/open-pdf-studio/js/tools/tool-dispatcher.js +++ b/open-pdf-studio/js/tools/tool-dispatcher.js @@ -577,6 +577,14 @@ export function handleDblClick(e) { showProperties(clicked); redraw(); } + } else if (clicked.type === 'fileAttachment') { + // Bijlage-annotatie (#376): het bestand openen in het standaardprogramma. + state.isDrawing = false; + if (dblDoc) { dblDoc.selectedAnnotations = [clicked]; dblDoc.selectedAnnotation = clicked; } + showProperties(clicked); + import('../ui/bijlage-annotaties.js') + .then((m) => m.openBijlageAnnotatie(clicked)) + .catch((err) => console.error('[dispatcher] open attachment failed', err)); } else if (clicked.type === 'comment') { state.isDrawing = false; if (dblDoc) { dblDoc.selectedAnnotations = [clicked]; dblDoc.selectedAnnotation = clicked; } diff --git a/open-pdf-studio/js/tools/tools/index.js b/open-pdf-studio/js/tools/tools/index.js index 53b7d2cd1..52fb3d7c8 100644 --- a/open-pdf-studio/js/tools/tools/index.js +++ b/open-pdf-studio/js/tools/tools/index.js @@ -17,7 +17,7 @@ import { measureDistanceTool, measureAreaTool, measurePerimeterTool, addHoleTool import { filledAreaTool } from './filled-area-tool.js'; import { systeemrasterTool, systeemplafondTool } from './systeemraster-tool.js'; import { measureAngleTool } from './angle-tool.js'; -import { commentTool, textTool, stampTool, signatureTool, editTextTool } from './text-tool.js'; +import { commentTool, fileAttachmentTool, textTool, stampTool, signatureTool, editTextTool } from './text-tool.js'; import { calibrationPickTool } from './calibration-pick-tool.js'; import { scaleMeasureTool } from './scale-measure-tool.js'; import { labelGebiedTool, LABEL_GEBIED_TOOL } from './label-gebied-tool.js'; @@ -128,6 +128,7 @@ export function registerAllTools() { // Single-click placement registerTool('comment', commentTool); + registerTool('fileAttachment', fileAttachmentTool); registerTool('text', textTool); registerTool('stamp', stampTool); registerTool('signature', signatureTool); diff --git a/open-pdf-studio/js/tools/tools/select-tool.js b/open-pdf-studio/js/tools/tools/select-tool.js index 9423097ff..f9e854554 100644 --- a/open-pdf-studio/js/tools/tools/select-tool.js +++ b/open-pdf-studio/js/tools/tools/select-tool.js @@ -493,7 +493,12 @@ export const selectTool = { } state.hoverAnnotation = hoverAnnotation || null; canvas.title = (hoverAnnotation?.type === 'comment' && !hoverAnnotation.popupOpen && hoverAnnotation.text) - ? hoverAnnotation.text.split('\n').slice(0, 5).join('\n') : ''; + ? hoverAnnotation.text.split('\n').slice(0, 5).join('\n') + // Bijlage-annotatie (#376): de bestandsnaam en de beschrijving. + : hoverAnnotation?.type === 'fileAttachment' + ? [hoverAnnotation.attachment?.name, hoverAnnotation.text !== hoverAnnotation.attachment?.name ? hoverAnnotation.text : ''] + .filter(Boolean).join('\n') + : ''; }, // Escape: één niveau terug — sub-element → component → (dispatcher wist diff --git a/open-pdf-studio/js/tools/tools/text-tool.js b/open-pdf-studio/js/tools/tools/text-tool.js index f93af5eae..0df3d1667 100644 --- a/open-pdf-studio/js/tools/tools/text-tool.js +++ b/open-pdf-studio/js/tools/tools/text-tool.js @@ -22,6 +22,21 @@ export const commentTool = { }, }; +// Bijlage-annotatie (#376): klik, kies een bestand, het pictogram staat er. +export const fileAttachmentTool = { + name: 'fileAttachment', + cursor: 'crosshair', + + onPointerDown(ctx, e) { + if (e && (e.button === 2 || e.detail === 2)) return; + const { x, y, pageNum } = ctx; + import('../../ui/bijlage-annotaties.js') + .then((m) => m.plaatsBijlageAnnotatie(x, y, pageNum)) + .catch((err) => console.error('[fileAttachment] place failed', err)) + .finally(() => import('../../tools/manager.js').then((m) => m.maybeRevertToSelect && m.maybeRevertToSelect())); + }, +}; + export const textTool = { name: 'text', cursor: 'text', diff --git a/open-pdf-studio/js/types/annotation.ts b/open-pdf-studio/js/types/annotation.ts index dd2584df1..2a17cafaf 100644 --- a/open-pdf-studio/js/types/annotation.ts +++ b/open-pdf-studio/js/types/annotation.ts @@ -2,7 +2,7 @@ export type AnnotationType = | 'highlight' | 'textHighlight' | 'textStrikethrough' | 'textUnderline' | 'textSquiggly' | 'caret' | 'draw' | 'line' | 'arrow' | 'box' | 'circle' - | 'textbox' | 'callout' | 'comment' | 'stamp' | 'image' | 'signature' + | 'textbox' | 'callout' | 'comment' | 'fileAttachment' | 'stamp' | 'image' | 'signature' | 'polygon' | 'cloud' | 'cloudPolyline' | 'polyline' | 'text' | 'arc' | 'spline' | 'splineArrow' | 'redaction' | 'measureDistance' | 'measureArea' | 'measurePerimeter' | 'measureAngle' diff --git a/open-pdf-studio/js/ui/bijlage-annotaties.js b/open-pdf-studio/js/ui/bijlage-annotaties.js new file mode 100644 index 000000000..48e705d51 --- /dev/null +++ b/open-pdf-studio/js/ui/bijlage-annotaties.js @@ -0,0 +1,124 @@ +// Bijlage-annotaties in de app (#376): plaatsen, openen en opslaan. +// +// Het model: { type: 'fileAttachment', x, y, width, height, icon, color, +// text (de beschrijving, /Contents), attachment: { key, name, description, +// mime, size, createdAt, modifiedAt, relationship, pdfjsId } }. De bytes +// staan in de bijlage-store (annotations/bijlage-store.js); de saver schrijft +// /FileAttachment met /FS en /EF (pdf/bijlage-annotatie.js). + +import i18next from '../i18n/config.js'; +import { state, getActiveDocument } from '../core/state.js'; +import { redrawAnnotations, redrawContinuous } from '../annotations/rendering.js'; +import { recordAdd } from '../core/undo-manager.js'; +import { layerForNewAnnotation } from '../annotations/annotatie-lagen.js'; +import { showProperties } from './panels/properties-panel.js'; +import { bewaarBijlageInhoud, bijlageInhoud } from '../annotations/bijlage-store.js'; +import { + BIJLAGE_ICOON_MAAT, STANDAARD_BIJLAGE_ICOON, STANDAARD_BIJLAGE_KLEUR, +} from '../annotations/bijlage-iconen.js'; +import { showMessage } from '../solid/stores/dialogStore.js'; + +function nieuweId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 11); +} + +function hertekenen(doc) { + if (doc?.viewMode === 'continuous') redrawContinuous(); + else redrawAnnotations(); +} + +/** + * Een nieuwe bijlage-annotatie (zonder haar aan een document toe te voegen). + * @param {{ x:number, y:number, page:number, name:string, bytes:Uint8Array, mime?:string }} p + */ +export function maakBijlageAnnotatie({ x, y, page, name, bytes, mime }) { + const nu = new Date().toISOString(); + return { + id: nieuweId(), + type: 'fileAttachment', + page, + x, + y, + width: BIJLAGE_ICOON_MAAT, + height: BIJLAGE_ICOON_MAAT, + text: name, + color: STANDAARD_BIJLAGE_KLEUR, + icon: STANDAARD_BIJLAGE_ICOON, + opacity: 1, + author: state.defaultAuthor, + createdAt: nu, + modifiedAt: nu, + locked: false, + printable: true, + attachment: { + key: bewaarBijlageInhoud({ bytes }), + name, + description: name, + mime: mime || null, + size: bytes.length, + createdAt: nu, + modifiedAt: nu, + relationship: null, + pdfjsId: null, + }, + }; +} + +/** + * Het gereedschap: kies een bestand en zet het pictogram op (x, y). + */ +export async function plaatsBijlageAnnotatie(x, y, pageNum) { + const doc = getActiveDocument(); + if (!doc?.pdfDoc) return null; + const page = pageNum || doc.currentPage || 1; + const { kiesBestanden, guessMimeType } = await import('./panels/attachments.js'); + let gekozen; + try { + gekozen = await kiesBestanden({ multiple: false }); + } catch (e) { + console.error('Failed to pick attachment file:', e); + return null; + } + if (!gekozen.length || getActiveDocument() !== doc) return null; + const { name, bytes } = gekozen[0]; + const ann = maakBijlageAnnotatie({ x, y, page, name, bytes, mime: guessMimeType(name) }); + const laag = layerForNewAnnotation(doc); + if (laag) ann.layer = laag; + doc.annotations.push(ann); + recordAdd(ann); + doc.selectedAnnotations = [ann]; + doc.selectedAnnotation = ann; + hertekenen(doc); + showProperties(ann); + ververstBijlagenPaneel(); + return ann; +} + +function ververstBijlagenPaneel() { + import('./panels/attachments.js').then((m) => m.updateAttachmentsList()).catch(() => {}); +} + +async function inhoudOfMelding(ann) { + const inhoud = await bijlageInhoud(ann, getActiveDocument()); + if (inhoud?.bytes) return inhoud.bytes; + showMessage(i18next.t('properties:attachment.noContent')); + return null; +} + +/** Dubbelklik of de knop Openen: het bestand in het standaardprogramma. */ +export async function openBijlageAnnotatie(ann) { + if (ann?.type !== 'fileAttachment') return; + const bytes = await inhoudOfMelding(ann); + if (!bytes) return; + const { openAttachmentExternal } = await import('./panels/attachments.js'); + await openAttachmentExternal(ann.attachment?.name || 'attachment', bytes); +} + +/** Opslaan als: het bestand naar schijf (of een download in de browser). */ +export async function bewaarBijlageAnnotatie(ann) { + if (ann?.type !== 'fileAttachment') return; + const bytes = await inhoudOfMelding(ann); + if (!bytes) return; + const { saveAttachmentToDisk } = await import('./panels/attachments.js'); + await saveAttachmentToDisk(ann.attachment?.name || 'attachment', bytes); +} diff --git a/open-pdf-studio/js/ui/chrome/tabs.js b/open-pdf-studio/js/ui/chrome/tabs.js index c9ce11037..d8ad3c3d2 100644 --- a/open-pdf-studio/js/ui/chrome/tabs.js +++ b/open-pdf-studio/js/ui/chrome/tabs.js @@ -357,7 +357,9 @@ export async function closeTab(index, force = false, dialogAction = null) { // het ruimt ook de tijdelijke werkbestanden van het document op, en wie het // laatste tabblad sluit en meteen afsluit, liet die anders staan (#400). const vrijgave = import('../../pdf/document-release.js') - .then(({ geefDocumentVrij }) => geefDocumentVrij(doc, state.documents)) + .then(({ geefDocumentVrij }) => geefDocumentVrij(doc, state.documents, { + klembord: [state.clipboardAnnotation, state.clipboardAnnotations], + })) .catch((e) => console.warn('[tabs] vrijgeven van document mislukt:', e)); // Een gesloten document gebruikt zijn knipsel-bronnen niet meer; wat nergens diff --git a/open-pdf-studio/js/ui/panels/attachments.js b/open-pdf-studio/js/ui/panels/attachments.js index 3b13b6a9e..e874fa3e8 100644 --- a/open-pdf-studio/js/ui/panels/attachments.js +++ b/open-pdf-studio/js/ui/panels/attachments.js @@ -5,7 +5,11 @@ import { isTauri, writeBinaryFile, readBinaryFile } from '../../core/platform.js import { slaOverInHeadless } from '../../core/headless.js'; import { biedDownloadAan, biedBijlageAan } from '../../core/extern-openen.js'; import { markDocumentModified } from '../chrome/tabs.js'; -import { wijzigingenVan, vrijeNaam, metToegevoegd, metVerwijderd, zichtbareBijlagen } from '../../pdf/bijlagen.js'; +import { bijlageInhoud } from '../../annotations/bijlage-store.js'; +import { + wijzigingenVan, vrijeNaam, metToegevoegd, metVerwijderd, zichtbareBijlagen, + isUitvoerbaarBestand, tijdelijkeBijlageNaam, +} from '../../pdf/bijlagen.js'; import { setAttachmentItems as setItems, setAttachmentCountText as setCountText, setAttachmentEmptyMessage as setEmptyMessage, setAttachmentSelectedKey as setSelectedKey, @@ -45,6 +49,8 @@ function formatDate(dateStr) { async function attachmentContent(key) { const att = currentAttachments[key]; if (!att) return null; + // Bijlage-annotatie (#376): de inhoud staat in de bijlage-store. + if (att.annotation) return (await bijlageInhoud(att.annotation, getActiveDocument()))?.bytes || null; if (!att.content && currentPdfDoc && typeof currentPdfDoc.getAttachmentContent === 'function') { try { const bytes = await currentPdfDoc.getAttachmentContent(key); @@ -79,7 +85,7 @@ export function selectAttachment(key) { } // Save a single attachment to disk -async function saveAttachmentToDisk(filename, content) { +export async function saveAttachmentToDisk(filename, content) { if (isTauri() && window.__TAURI__?.dialog) { try { const savePath = await window.__TAURI__.dialog.save({ @@ -97,14 +103,31 @@ async function saveAttachmentToDisk(filename, content) { } } +// Bestanden die het systeem uitvoert in plaats van opent: eerst vragen +// (de lijst staat in pdf/bijlagen.js, daar ook getest). +export { isUitvoerbaarBestand }; + // Open attachment in default application -async function openAttachmentExternal(filename, content) { +export async function openAttachmentExternal(filename, content) { // Headless (testinstantie): geen ander programma naar voren halen. if (slaOverInHeadless(`open attachment ${filename}`)) return; if (isTauri() && window.__TAURI__?.fs && window.__TAURI__?.path && window.__TAURI__?.shell) { + // Alleen de bestandsnaam: een naam uit een PDF mag niet buiten de + // tijdelijke map schrijven. + const veilig = tijdelijkeBijlageNaam(filename); + if (isUitvoerbaarBestand(filename) || isUitvoerbaarBestand(veilig)) { + const { showConfirm } = await import('../chrome/confirm-dialog.js'); + const ja = await showConfirm({ + title: i18next.t('leftPanel.openAttachmentRiskTitle'), + message: i18next.t('leftPanel.openAttachmentRiskMessage', { name: filename }), + }); + if (!ja) return; + } try { const tempDir = await window.__TAURI__.path.tempDir(); - const tempPath = tempDir + filename; + const tempPath = typeof window.__TAURI__.path.join === 'function' + ? await window.__TAURI__.path.join(tempDir, veilig) + : tempDir.replace(/[\\/]?$/, '/') + veilig; await writeBinaryFile(tempPath, content); await window.__TAURI__.shell.open(tempPath); } catch (e) { @@ -148,40 +171,52 @@ export async function saveAllAttachments() { } } -// Toolbar: Add attachment via file picker or browser input -export async function addAttachment() { - const activeDoc = getActiveDocument(); - if (!activeDoc || !activeDoc.pdfDoc) return; - +/** + * Laat de gebruiker bestanden kiezen: op het bureaublad de dialoog van het + * systeem, in de browser een . Geeft [{ name, bytes }], leeg + * bij annuleren. + * @param {{ multiple?: boolean }} [opties] + */ +export async function kiesBestanden({ multiple = true } = {}) { if (isTauri() && window.__TAURI__?.dialog) { - try { - const filePath = await window.__TAURI__.dialog.open({ - multiple: true, - filters: [{ name: 'All Files', extensions: ['*'] }] - }); - if (!filePath) return; - const paths = Array.isArray(filePath) ? filePath : [filePath]; - for (const fp of paths) { - const fileBytes = await readBinaryFile(fp); - const name = fp.split(/[\\/]/).pop(); - await embedAttachment(activeDoc, name, new Uint8Array(fileBytes)); - } - updateAttachmentsList(); - } catch (e) { - console.error('Failed to add attachment:', e); + const filePath = await window.__TAURI__.dialog.open({ + multiple, + filters: [{ name: 'All Files', extensions: ['*'] }] + }); + if (!filePath) return []; + const paths = Array.isArray(filePath) ? filePath : [filePath]; + const uit = []; + for (const fp of paths) { + const fileBytes = await readBinaryFile(fp); + uit.push({ name: fp.split(/[\\/]/).pop(), bytes: new Uint8Array(fileBytes) }); } - } else { + return uit; + } + return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; - input.multiple = true; + input.multiple = multiple; input.onchange = async () => { - for (const file of input.files) { - const bytes = new Uint8Array(await file.arrayBuffer()); - await embedAttachment(activeDoc, file.name, bytes); - } - updateAttachmentsList(); + const uit = []; + for (const file of input.files) uit.push({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) }); + resolve(uit); }; + input.oncancel = () => resolve([]); input.click(); + }); +} + +// Toolbar: Add attachment via file picker or browser input +export async function addAttachment() { + const activeDoc = getActiveDocument(); + if (!activeDoc || !activeDoc.pdfDoc) return; + try { + const gekozen = await kiesBestanden({ multiple: true }); + if (!gekozen.length || getActiveDocument() !== activeDoc) return; + for (const { name, bytes } of gekozen) embedAttachment(activeDoc, name, bytes); + updateAttachmentsList(); + } catch (e) { + console.error('Failed to add attachment:', e); } } @@ -191,7 +226,21 @@ export async function deleteSelectedAttachment() { const activeDoc = getActiveDocument(); if (!activeDoc || !activeDoc.pdfDoc) return; - await removeAttachment(activeDoc, selectedKeyLocal); + const annot = currentAttachments[selectedKeyLocal]?.annotation; + if (annot) { + // Bijlage-annotatie (#376): de annotatie gaat weg, met ongedaan maken. + const { deleteAnnotationsWithUndo } = await import('../../annotations/mutations.js'); + if (!deleteAnnotationsWithUndo(activeDoc, [annot]).length) return; + if (activeDoc.selectedAnnotations?.includes(annot)) { + activeDoc.selectedAnnotations = []; + activeDoc.selectedAnnotation = null; + } + const r = await import('../../annotations/rendering.js'); + if (activeDoc.viewMode === 'continuous') r.redrawContinuous(); else r.redrawAnnotations(); + markDocumentModified(activeDoc); + } else { + await removeAttachment(activeDoc, selectedKeyLocal); + } selectedKeyLocal = null; setSelectedKey(null); updateAttachmentsList(); @@ -217,7 +266,7 @@ function removeAttachment(activeDoc, key) { } // Guess MIME type from filename -function guessMimeType(filename) { +export function guessMimeType(filename) { const ext = (filename || '').split('.').pop().toLowerCase(); const types = { pdf: 'application/pdf', txt: 'text/plain', html: 'text/html', htm: 'text/html', @@ -286,6 +335,21 @@ export async function updateAttachmentsList() { ? { filename: att.naam, content: att.bytes, description: att.beschrijving || null, createdAt: att.aangemaakt || null, modifiedAt: att.gewijzigd || null } : att; } + // Bijlage-annotaties (#376) op de pagina's, zoals andere lezers ze ook in + // hun bijlagenlijst tonen: met het paginanummer erbij. + for (const ann of (activeDoc.annotations || []).filter(a => a.type === 'fileAttachment')) { + const a = ann.attachment || {}; + attachments[`annot:${ann.id}`] = { + filename: a.name || 'attachment', + content: null, + size: a.size ?? null, + description: ann.text || a.description || null, + createdAt: a.createdAt || null, + modifiedAt: a.modifiedAt || null, + page: ann.page, + annotation: ann, + }; + } if (Object.keys(attachments).length === 0) { currentAttachments = {}; @@ -309,6 +373,9 @@ export async function updateAttachmentsList() { description: att.description || null, createdAt: att.createdAt || null, modifiedAt: att.modifiedAt || null, + size: att.size ?? null, + page: att.page ?? null, + annotation: att.annotation || null, }; }); @@ -322,9 +389,11 @@ export async function updateAttachmentsList() { const itemsArray = keys.map(key => { const att = currentAttachments[key]; // De grootte is bekend zodra de bytes dat zijn (toegevoegd, of gelezen). - const parts = att.content ? [formatFileSize(att.content.length)] : []; - if (att.createdAt) parts.push(`Created: ${formatDate(att.createdAt)}`); - else if (att.modifiedAt) parts.push(`Modified: ${formatDate(att.modifiedAt)}`); + const grootte = att.content ? att.content.length : att.size; + const parts = typeof grootte === 'number' ? [formatFileSize(grootte)] : []; + if (att.page) parts.push(i18next.t('leftPanel.attachmentOnPage', { page: att.page })); + if (att.createdAt) parts.push(i18next.t('leftPanel.attachmentCreated', { date: formatDate(att.createdAt) })); + else if (att.modifiedAt) parts.push(i18next.t('leftPanel.attachmentModified', { date: formatDate(att.modifiedAt) })); const metaText = parts.join(' | '); return { diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index 74e74313a..cf87dae22 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -321,6 +321,9 @@ js/core/host-brug.test.mjs js/core/blob-worker.test.mjs js/core/werkplek-ai.test.mjs js/pdf/bijlagen.test.mjs +js/pdf/bijlage-annotatie.test.mjs +js/pdf/bijlage-rondgang.test.mjs +js/i18n/bijlagen-i18n.test.mjs js/watermark/kopvoet.test.mjs js/pdf/page-manager-kopvoet.test.mjs js/pdf/pdfx-enrich.test.mjs diff --git a/open-pdf-studio/styles/properties-panel.css b/open-pdf-studio/styles/properties-panel.css index 79e79b8bd..0396ef934 100644 --- a/open-pdf-studio/styles/properties-panel.css +++ b/open-pdf-studio/styles/properties-panel.css @@ -717,6 +717,12 @@ cursor: not-allowed; } +/* Knoppen naast elkaar (bijlage-annotatie: Openen / Opslaan als, #376) */ +.prop-button-row { + display: flex; + gap: 4px; +} + /* ── Lijnstijl-presets (WEERGAVE-sectie, issue #268) ─────────────────────── */ From 66089873d09af956faa5c1202ff4a0a9a58bb8b6 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 09:54:53 +0100 Subject: [PATCH 05/13] feat(move): inference tracking, extension and parallel guides while moving (#786) Move mode (G / mv) gets an inference engine on top of object snap: - Tracking lines: the cursor or a snap point of the moving selection aligns horizontally/vertically with endpoints, midpoints, corners and centres of other geometry (annotations and, when enabled, PDF content); H/V through the base point; hovering a point briefly acquires it as a tracking point, several guides give intersection snaps. - Extension lines of existing lines and wall faces beyond their endpoints. - Parallel / perpendicular to nearby lines, with guide and marker. - Polar guides around the base point from the existing polar settings. - Colour coding (red X, green Y, magenta parallel/perpendicular) with a tooltip naming the inference and the distance; guides drawn in single-page and continuous mode, axis colours follow a rotated view. - Shift locks the shown guide (falls back to the angle constraint when no guide is shown); X/Y axis lock intersects with guides. - A typed distance moves along the inferred direction; Tab switches the typed entry between distance and angle. - Priority: point object snaps > guide intersections > single guides > edge/grid. The selection never infers from or snaps onto itself. The pure geometry lives in js/tools/inference-engine.js (sorted-axis and grid point index, segment grid), session glue in move-inference.js and the overlay in inference-overlay.js. Corner-to-corner object snap now uses the point index instead of a 6000-point cap. New preferences toggle tracking, extension and parallel inference, translated for all locales. Fixes on top during review: with the Object snap master switch off the move is free again; tracking (including the H/V guides through the base point), extension and parallel guides are disabled then, polar stays independent as in the snap engine. The distance in the guide tooltip uses the real measure scale and shows points on a page without a scale instead of the 1 pt = 1 mm fallback. Regression tests for both. Fixes #519 --- open-pdf-studio/js/annotations/rendering.js | 14 + open-pdf-studio/js/core/constants.ts | 7 + .../js/i18n/locales/ar/common.json | 11 + .../js/i18n/locales/ar/preferences.json | 5 + .../js/i18n/locales/bg/common.json | 11 + .../js/i18n/locales/bg/preferences.json | 5 + .../js/i18n/locales/bn/common.json | 11 + .../js/i18n/locales/bn/preferences.json | 5 + .../js/i18n/locales/ca/common.json | 11 + .../js/i18n/locales/ca/preferences.json | 5 + .../js/i18n/locales/cs/common.json | 11 + .../js/i18n/locales/cs/preferences.json | 5 + .../js/i18n/locales/da/common.json | 11 + .../js/i18n/locales/da/preferences.json | 5 + .../js/i18n/locales/de/common.json | 11 + .../js/i18n/locales/de/preferences.json | 5 + .../js/i18n/locales/el/common.json | 11 + .../js/i18n/locales/el/preferences.json | 5 + .../js/i18n/locales/en/common.json | 11 + .../js/i18n/locales/en/preferences.json | 5 + .../js/i18n/locales/es/common.json | 11 + .../js/i18n/locales/es/preferences.json | 5 + .../js/i18n/locales/fa/common.json | 11 + .../js/i18n/locales/fa/preferences.json | 5 + .../js/i18n/locales/fi/common.json | 11 + .../js/i18n/locales/fi/preferences.json | 5 + .../js/i18n/locales/fr/common.json | 11 + .../js/i18n/locales/fr/preferences.json | 5 + .../js/i18n/locales/he/common.json | 11 + .../js/i18n/locales/he/preferences.json | 5 + .../js/i18n/locales/hi/common.json | 11 + .../js/i18n/locales/hi/preferences.json | 5 + .../js/i18n/locales/hr/common.json | 11 + .../js/i18n/locales/hr/preferences.json | 5 + .../js/i18n/locales/hu/common.json | 11 + .../js/i18n/locales/hu/preferences.json | 5 + .../js/i18n/locales/id/common.json | 11 + .../js/i18n/locales/id/preferences.json | 5 + .../js/i18n/locales/it/common.json | 11 + .../js/i18n/locales/it/preferences.json | 5 + .../js/i18n/locales/ja/common.json | 11 + .../js/i18n/locales/ja/preferences.json | 5 + .../js/i18n/locales/ko/common.json | 11 + .../js/i18n/locales/ko/preferences.json | 5 + .../js/i18n/locales/lo/common.json | 11 + .../js/i18n/locales/lo/preferences.json | 5 + .../js/i18n/locales/ms/common.json | 11 + .../js/i18n/locales/ms/preferences.json | 5 + .../js/i18n/locales/nb/common.json | 11 + .../js/i18n/locales/nb/preferences.json | 5 + .../js/i18n/locales/nl/common.json | 11 + .../js/i18n/locales/nl/preferences.json | 5 + .../js/i18n/locales/pl/common.json | 11 + .../js/i18n/locales/pl/preferences.json | 5 + .../js/i18n/locales/pt/common.json | 11 + .../js/i18n/locales/pt/preferences.json | 5 + .../js/i18n/locales/ro/common.json | 11 + .../js/i18n/locales/ro/preferences.json | 5 + .../js/i18n/locales/ru/common.json | 11 + .../js/i18n/locales/ru/preferences.json | 5 + .../js/i18n/locales/sk/common.json | 11 + .../js/i18n/locales/sk/preferences.json | 5 + .../js/i18n/locales/sr/common.json | 11 + .../js/i18n/locales/sr/preferences.json | 5 + .../js/i18n/locales/sv/common.json | 11 + .../js/i18n/locales/sv/preferences.json | 5 + .../js/i18n/locales/sw/common.json | 11 + .../js/i18n/locales/sw/preferences.json | 5 + .../js/i18n/locales/ta/common.json | 11 + .../js/i18n/locales/ta/preferences.json | 5 + .../js/i18n/locales/th/common.json | 11 + .../js/i18n/locales/th/preferences.json | 5 + .../js/i18n/locales/tr/common.json | 11 + .../js/i18n/locales/tr/preferences.json | 5 + .../js/i18n/locales/uk/common.json | 11 + .../js/i18n/locales/uk/preferences.json | 5 + .../js/i18n/locales/ur/common.json | 11 + .../js/i18n/locales/ur/preferences.json | 5 + .../js/i18n/locales/vi/common.json | 11 + .../js/i18n/locales/vi/preferences.json | 5 + .../js/i18n/locales/zh/common.json | 11 + .../js/i18n/locales/zh/preferences.json | 5 + .../js/solid/components/TypeLengthHUD.jsx | 17 +- .../components/preferences/BehaviorTab.jsx | 25 + open-pdf-studio/js/tools/g-move-mode.js | 266 ++++++-- open-pdf-studio/js/tools/inference-engine.js | 573 ++++++++++++++++++ .../js/tools/inference-engine.test.mjs | 217 +++++++ open-pdf-studio/js/tools/inference-overlay.js | 119 ++++ .../js/tools/move-inference-i18n.test.mjs | 49 ++ open-pdf-studio/js/tools/move-inference.js | 285 +++++++++ .../js/tools/move-inference.test.mjs | 163 +++++ open-pdf-studio/js/tools/snap-engine.js | 2 +- open-pdf-studio/js/tools/type-length-input.js | 52 ++ .../js/tools/type-length-tab.test.mjs | 76 +++ open-pdf-studio/js/types/preferences.ts | 3 + open-pdf-studio/scripts/unit-testlijst.txt | 4 + 96 files changed, 2454 insertions(+), 58 deletions(-) create mode 100644 open-pdf-studio/js/tools/inference-engine.js create mode 100644 open-pdf-studio/js/tools/inference-engine.test.mjs create mode 100644 open-pdf-studio/js/tools/inference-overlay.js create mode 100644 open-pdf-studio/js/tools/move-inference-i18n.test.mjs create mode 100644 open-pdf-studio/js/tools/move-inference.js create mode 100644 open-pdf-studio/js/tools/move-inference.test.mjs create mode 100644 open-pdf-studio/js/tools/type-length-tab.test.mjs diff --git a/open-pdf-studio/js/annotations/rendering.js b/open-pdf-studio/js/annotations/rendering.js index ce5f21fe0..cb588c5f8 100644 --- a/open-pdf-studio/js/annotations/rendering.js +++ b/open-pdf-studio/js/annotations/rendering.js @@ -51,6 +51,7 @@ import { bitmapVoor, bijNieuweTegel, KNIPSEL_UITVOER_FOUT } from './vector-snipp import { invalidateScaleRegionCache, pixelsPerUnitFor, getRegionScaleFactor } from './scale-region.js'; import { metSchaalBronnen } from './schaal-bronnen.js'; import { drawSnapIndicator } from '../tools/snap-engine.js'; +import { drawMoveInferenceOverlay } from '../tools/inference-overlay.js'; import { drawImageAlignGuides } from '../tools/image-align-snap.js'; import { getTemplate } from '../symbols/registry.js'; import { hasFill, hasStroke } from './fill-utils.js'; @@ -3247,6 +3248,9 @@ export function redrawAnnotations(lightweight = false) { state.lastSnapResult.type !== 'polar') { drawSnapIndicator(annotationCtx, state.lastSnapResult, effectiveScale); } + // Inference guides of a move session (#519): tracking/extension/parallel/ + // polar lines, acquired points and the tooltip naming the inference. + if (state.gMoveMode) drawMoveInferenceOverlay(annotationCtx, curPage, effectiveScale); // Image alignment guides (edge/centre snap while moving, equal-width/height // while resizing). State-driven — same principle as the rubber-band and the @@ -3449,6 +3453,16 @@ function tekenPaginaLagen(ctx, pageNum, width, height, overrideDpr, renderOffset } } + // Move session (continuous): snap indicator and inference guides (#519) on + // the page of the moved selection — this path never drew them before. + if (lagen.bewerkhulp && state.gMoveMode) { + const snap = state.lastSnapResult; + if (snap && snap.snapped && snap.type !== 'polar' && snap.page === pageNum) { + drawSnapIndicator(ctx, snap, effectiveScale); + } + drawMoveInferenceOverlay(ctx, pageNum, effectiveScale); + } + // Rubber-band selection marquee on the page the drag started on (continuous). if (lagen.bewerkhulp && state.isRubberBanding && state.rubberBandPage === pageNum) { drawRubberBand(ctx, effectiveScale); diff --git a/open-pdf-studio/js/core/constants.ts b/open-pdf-studio/js/core/constants.ts index 03da01467..3256bc66f 100644 --- a/open-pdf-studio/js/core/constants.ts +++ b/open-pdf-studio/js/core/constants.ts @@ -100,6 +100,13 @@ export const DEFAULT_PREFERENCES: Preferences = { // guide lines); while resizing, its width/height snaps to another image's // width/height ("equal width"). Uses objectSnapRadius as its tolerance. enableImageAlignSnap: true, + // Move inference (#519, G / mv): tracking lines through points of other + // geometry (and acquired points), extension lines of lines / wall faces, + // and parallel / perpendicular guides. Polar during a move follows the + // polar-tracking settings above. + moveInferenceTracking: true, + moveInferenceExtension: true, + moveInferenceParallel: true, // CAD behavior — keep drawing tool active after committing one annotation // so the user can place multiple in a row without re-selecting the tool. // Esc returns to select. diff --git a/open-pdf-studio/js/i18n/locales/ar/common.json b/open-pdf-studio/js/i18n/locales/ar/common.json index 7362dadf5..92bd62ff3 100644 --- a/open-pdf-studio/js/i18n/locales/ar/common.json +++ b/open-pdf-studio/js/i18n/locales/ar/common.json @@ -303,5 +303,16 @@ "readFailed": "تعذرت قراءة نموذج 3D.", "partial": "تعذرت قراءة بعض أجزاء نموذج 3D (تم تخطي {{n}}).", "viewerFailed": "تعذر بدء عرض 3D." + }, + "moveInference": { + "onAxisX": "على المحور X", + "onAxisY": "على المحور Y", + "fromPoint": "من نقطة", + "extension": "على الامتداد", + "parallel": "موازٍ", + "perpendicular": "عمودي", + "intersection": "تقاطع", + "polar": "قطبي", + "locked": "مثبّت" } } diff --git a/open-pdf-studio/js/i18n/locales/ar/preferences.json b/open-pdf-studio/js/i18n/locales/ar/preferences.json index 63fd46fa1..9f71b594c 100644 --- a/open-pdf-studio/js/i18n/locales/ar/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ar/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "الانطباق على الأقرب", "objectSnapRadius": "نصف قطر الانطباق (بالبكسل)", "snapToPdfContent": "الانطباق على محتوى رسم PDF", + "moveInference": "خطوط الاستدلال أثناء النقل (G / mv)", + "moveInferenceTracking": "خطوط التتبع (المحاذاة مع نقاط هندسة أخرى)", + "moveInferenceExtension": "امتدادات الخطوط وأوجه الجدران", + "moveInferenceParallel": "موازٍ / عمودي على الخطوط القريبة", + "moveInferenceHint": "مرّر المؤشر لحظة فوق نقطة لالتقاطها، واضغط مع الاستمرار على Shift لتثبيت خط إرشادي، ويبدّل Tab بين المسافة والزاوية. تتبع الخطوط القطبية إعدادات التتبع القطبي.", "creation": "الإنشاء", "autoSelectAfterCreation": "تحديد التعليق تلقائيًا بعد الإنشاء", "deletion": "الحذف", diff --git a/open-pdf-studio/js/i18n/locales/bg/common.json b/open-pdf-studio/js/i18n/locales/bg/common.json index 699e2b072..53b192265 100644 --- a/open-pdf-studio/js/i18n/locales/bg/common.json +++ b/open-pdf-studio/js/i18n/locales/bg/common.json @@ -303,5 +303,16 @@ "readFailed": "3D моделът не можа да бъде прочетен.", "partial": "Някои части от 3D модела не можаха да бъдат прочетени (пропуснати: {{n}}).", "viewerFailed": "3D изгледът не можа да бъде стартиран." + }, + "moveInference": { + "onAxisX": "По оста X", + "onAxisY": "По оста Y", + "fromPoint": "От точка", + "extension": "По продължението", + "parallel": "Успоредно", + "perpendicular": "Перпендикулярно", + "intersection": "Пресечна точка", + "polar": "Полярно", + "locked": "Заключено" } } diff --git a/open-pdf-studio/js/i18n/locales/bg/preferences.json b/open-pdf-studio/js/i18n/locales/bg/preferences.json index 8a12ff0f3..0096db3f2 100644 --- a/open-pdf-studio/js/i18n/locales/bg/preferences.json +++ b/open-pdf-studio/js/i18n/locales/bg/preferences.json @@ -166,6 +166,11 @@ "deletion": "Изтриване", "confirmBeforeDeleting": "Потвърждение преди изтриване на анотации", "snapToPdfContent": "Прилепване към съдържанието на PDF чертежа", + "moveInference": "Помощни линии при преместване (G / mv)", + "moveInferenceTracking": "Линии за проследяване (подравняване по точки на друга геометрия)", + "moveInferenceExtension": "Продължения на линии и стенни повърхности", + "moveInferenceParallel": "Успоредно / перпендикулярно на близки линии", + "moveInferenceHint": "Задръжте курсора за кратко над точка, за да я прихванете, задръжте Shift, за да заключите помощна линия, Tab превключва между разстояние и ъгъл. Полярните помощни линии следват настройките за полярно проследяване.", "navigation": "Навигация", "wheelZoomWithoutCtrl": "Мащабиране с колелцето на мишката (без задържане на Ctrl)", "middleDoubleClickFit": "Двойно щракване със средния бутон на мишката за побиране на страницата", diff --git a/open-pdf-studio/js/i18n/locales/bn/common.json b/open-pdf-studio/js/i18n/locales/bn/common.json index f8bfbfcf9..bf9d76dde 100644 --- a/open-pdf-studio/js/i18n/locales/bn/common.json +++ b/open-pdf-studio/js/i18n/locales/bn/common.json @@ -303,5 +303,16 @@ "readFailed": "3D মডেলটি পড়া যায়নি।", "partial": "3D মডেলের কিছু অংশ পড়া যায়নি ({{n}}টি বাদ দেওয়া হয়েছে)।", "viewerFailed": "3D ভিউ চালু করা যায়নি।" + }, + "moveInference": { + "onAxisX": "X অক্ষে", + "onAxisY": "Y অক্ষে", + "fromPoint": "বিন্দু থেকে", + "extension": "বর্ধিতাংশে", + "parallel": "সমান্তরাল", + "perpendicular": "লম্ব", + "intersection": "ছেদবিন্দু", + "polar": "পোলার", + "locked": "লক করা" } } diff --git a/open-pdf-studio/js/i18n/locales/bn/preferences.json b/open-pdf-studio/js/i18n/locales/bn/preferences.json index e61a4faed..fba472251 100644 --- a/open-pdf-studio/js/i18n/locales/bn/preferences.json +++ b/open-pdf-studio/js/i18n/locales/bn/preferences.json @@ -166,6 +166,11 @@ "deletion": "মুছে ফেলা", "confirmBeforeDeleting": "টীকা মুছে ফেলার আগে নিশ্চিত করুন", "snapToPdfContent": "PDF অঙ্কনের বিষয়বস্তুতে স্ন্যাপ করুন", + "moveInference": "সরানোর সময় সহায়ক রেখা (G / mv)", + "moveInferenceTracking": "ট্র্যাকিং রেখা (অন্য জ্যামিতির বিন্দুর সাথে সারিবদ্ধ)", + "moveInferenceExtension": "রেখা ও দেয়ালের তলের বর্ধিতাংশ", + "moveInferenceParallel": "কাছের রেখার সমান্তরাল / লম্ব", + "moveInferenceHint": "কোনো বিন্দু ধরতে কার্সারটি কিছুক্ষণ তার উপর রাখুন, কোনো গাইড লক করতে Shift চেপে ধরে রাখুন, Tab দূরত্ব ও কোণের মধ্যে বদলায়। পোলার গাইড পোলার ট্র্যাকিংয়ের সেটিংস অনুসরণ করে।", "navigation": "নেভিগেশন", "wheelZoomWithoutCtrl": "মাউস হুইল দিয়ে জুম করুন (Ctrl না চেপে)", "middleDoubleClickFit": "পৃষ্ঠা মানানসই করতে মাউসের মাঝের বোতামে ডাবল-ক্লিক করুন", diff --git a/open-pdf-studio/js/i18n/locales/ca/common.json b/open-pdf-studio/js/i18n/locales/ca/common.json index 9ffb30f4e..a8c8f0b29 100644 --- a/open-pdf-studio/js/i18n/locales/ca/common.json +++ b/open-pdf-studio/js/i18n/locales/ca/common.json @@ -303,5 +303,16 @@ "readFailed": "No s'ha pogut llegir el model 3D.", "partial": "No s'han pogut llegir algunes parts del model 3D ({{n}} omeses).", "viewerFailed": "No s'ha pogut iniciar la visualització 3D." + }, + "moveInference": { + "onAxisX": "A l’eix X", + "onAxisY": "A l’eix Y", + "fromPoint": "Des del punt", + "extension": "A la prolongació", + "parallel": "Paral·lel", + "perpendicular": "Perpendicular", + "intersection": "Intersecció", + "polar": "Polar", + "locked": "Bloquejat" } } diff --git a/open-pdf-studio/js/i18n/locales/ca/preferences.json b/open-pdf-studio/js/i18n/locales/ca/preferences.json index fe5218767..11517d009 100644 --- a/open-pdf-studio/js/i18n/locales/ca/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ca/preferences.json @@ -166,6 +166,11 @@ "deletion": "Supressió", "confirmBeforeDeleting": "Confirma abans de suprimir anotacions", "snapToPdfContent": "Ajusta al contingut del dibuix PDF", + "moveInference": "Inferència en moure (G / mv)", + "moveInferenceTracking": "Línies de rastreig (alinea amb punts d’altres geometries)", + "moveInferenceExtension": "Prolongacions de línies i cares de murs", + "moveInferenceParallel": "Paral·lel / perpendicular a línies properes", + "moveInferenceHint": "Atureu el cursor un moment sobre un punt per adquirir-lo, manteniu Maj premuda per bloquejar una guia, Tab alterna entre distància i angle. Les guies polars segueixen la configuració del rastreig polar.", "navigation": "Navegació", "wheelZoomWithoutCtrl": "Zoom amb la roda del ratolí (sense mantenir premut Ctrl)", "middleDoubleClickFit": "Feu doble clic amb el botó central del ratolí per ajustar la pàgina", diff --git a/open-pdf-studio/js/i18n/locales/cs/common.json b/open-pdf-studio/js/i18n/locales/cs/common.json index 34dc339a6..d5ea8bed3 100644 --- a/open-pdf-studio/js/i18n/locales/cs/common.json +++ b/open-pdf-studio/js/i18n/locales/cs/common.json @@ -303,5 +303,16 @@ "readFailed": "3D model se nepodařilo načíst.", "partial": "Některé části 3D modelu se nepodařilo načíst (vynecháno: {{n}}).", "viewerFailed": "3D zobrazení se nepodařilo spustit." + }, + "moveInference": { + "onAxisX": "Na ose X", + "onAxisY": "Na ose Y", + "fromPoint": "Od bodu", + "extension": "Na prodloužení", + "parallel": "Rovnoběžně", + "perpendicular": "Kolmo", + "intersection": "Průsečík", + "polar": "Polární", + "locked": "Uzamčeno" } } diff --git a/open-pdf-studio/js/i18n/locales/cs/preferences.json b/open-pdf-studio/js/i18n/locales/cs/preferences.json index c2973a947..08b84ee07 100644 --- a/open-pdf-studio/js/i18n/locales/cs/preferences.json +++ b/open-pdf-studio/js/i18n/locales/cs/preferences.json @@ -166,6 +166,11 @@ "deletion": "Mazání", "confirmBeforeDeleting": "Potvrdit před smazáním anotací", "snapToPdfContent": "Přichytit k obsahu výkresu PDF", + "moveInference": "Pomocné čáry při přesunu (G / mv)", + "moveInferenceTracking": "Trasovací čáry (zarovnání k bodům jiné geometrie)", + "moveInferenceExtension": "Prodloužení čar a líců stěn", + "moveInferenceParallel": "Rovnoběžně / kolmo k blízkým čarám", + "moveInferenceHint": "Krátce podržte kurzor nad bodem, aby se zachytil, podržte Shift pro uzamčení pomocné čáry, Tab přepíná mezi vzdáleností a úhlem. Polární pomocné čáry se řídí nastavením polárního trasování.", "navigation": "Navigace", "wheelZoomWithoutCtrl": "Lupa kolečkem myši (bez držení Ctrl)", "middleDoubleClickFit": "Dvojklik prostředním tlačítkem myši přizpůsobí stránku", diff --git a/open-pdf-studio/js/i18n/locales/da/common.json b/open-pdf-studio/js/i18n/locales/da/common.json index d3d1fc29f..b0be53821 100644 --- a/open-pdf-studio/js/i18n/locales/da/common.json +++ b/open-pdf-studio/js/i18n/locales/da/common.json @@ -303,5 +303,16 @@ "readFailed": "3D-modellen kunne ikke læses.", "partial": "Nogle dele af 3D-modellen kunne ikke læses ({{n}} sprunget over).", "viewerFailed": "3D-visningen kunne ikke startes." + }, + "moveInference": { + "onAxisX": "På X-aksen", + "onAxisY": "På Y-aksen", + "fromPoint": "Fra punkt", + "extension": "På forlængelse", + "parallel": "Parallel", + "perpendicular": "Vinkelret", + "intersection": "Skæringspunkt", + "polar": "Polær", + "locked": "Låst" } } diff --git a/open-pdf-studio/js/i18n/locales/da/preferences.json b/open-pdf-studio/js/i18n/locales/da/preferences.json index 89f1d1f3d..3246b7fe6 100644 --- a/open-pdf-studio/js/i18n/locales/da/preferences.json +++ b/open-pdf-studio/js/i18n/locales/da/preferences.json @@ -166,6 +166,11 @@ "deletion": "Sletning", "confirmBeforeDeleting": "Bekræft før sletning af annotationer", "snapToPdfContent": "Snap til PDF-tegningsindhold", + "moveInference": "Hjælpelinjer ved flytning (G / mv)", + "moveInferenceTracking": "Sporingslinjer (juster efter punkter i anden geometri)", + "moveInferenceExtension": "Forlængelser af linjer og vægflader", + "moveInferenceParallel": "Parallel / vinkelret på nærliggende linjer", + "moveInferenceHint": "Hold kort markøren over et punkt for at fange det, hold Shift nede for at låse en hjælpelinje, Tab skifter mellem afstand og vinkel. Polære hjælpelinjer følger indstillingerne for polær sporing.", "navigation": "Navigation", "wheelZoomWithoutCtrl": "Zoom med musehjulet (uden at holde Ctrl nede)", "middleDoubleClickFit": "Dobbeltklik med den midterste museknap for at tilpasse siden", diff --git a/open-pdf-studio/js/i18n/locales/de/common.json b/open-pdf-studio/js/i18n/locales/de/common.json index da439e21e..5b85a66fb 100644 --- a/open-pdf-studio/js/i18n/locales/de/common.json +++ b/open-pdf-studio/js/i18n/locales/de/common.json @@ -303,5 +303,16 @@ "readFailed": "Das 3D-Modell konnte nicht gelesen werden.", "partial": "Einige Teile des 3D-Modells konnten nicht gelesen werden ({{n}} übersprungen).", "viewerFailed": "Die 3D-Ansicht konnte nicht gestartet werden." + }, + "moveInference": { + "onAxisX": "Auf X-Achse", + "onAxisY": "Auf Y-Achse", + "fromPoint": "Von Punkt", + "extension": "Auf Verlängerung", + "parallel": "Parallel", + "perpendicular": "Senkrecht", + "intersection": "Schnittpunkt", + "polar": "Polar", + "locked": "Gesperrt" } } diff --git a/open-pdf-studio/js/i18n/locales/de/preferences.json b/open-pdf-studio/js/i18n/locales/de/preferences.json index e5b759345..08d030e41 100644 --- a/open-pdf-studio/js/i18n/locales/de/preferences.json +++ b/open-pdf-studio/js/i18n/locales/de/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Am nächstgelegenen Punkt einrasten", "objectSnapRadius": "Fangradius (Pixel)", "snapToPdfContent": "An PDF-Zeichnungsinhalt einrasten", + "moveInference": "Fanghilfen beim Verschieben (G / mv)", + "moveInferenceTracking": "Spurlinien (an Punkten anderer Geometrie ausrichten)", + "moveInferenceExtension": "Verlängerungen von Linien und Wandflächen", + "moveInferenceParallel": "Parallel / senkrecht zu Linien in der Nähe", + "moveInferenceHint": "Zeigen Sie kurz auf einen Punkt, um ihn zu erfassen, halten Sie Shift gedrückt, um eine Hilfslinie zu sperren, Tab wechselt zwischen Abstand und Winkel. Polare Hilfslinien folgen den Polarfang-Einstellungen.", "creation": "Erstellung", "autoSelectAfterCreation": "Anmerkung nach Erstellung automatisch auswählen", "deletion": "Löschen", diff --git a/open-pdf-studio/js/i18n/locales/el/common.json b/open-pdf-studio/js/i18n/locales/el/common.json index 06269b3c9..9bb60e46b 100644 --- a/open-pdf-studio/js/i18n/locales/el/common.json +++ b/open-pdf-studio/js/i18n/locales/el/common.json @@ -303,5 +303,16 @@ "readFailed": "Δεν ήταν δυνατή η ανάγνωση του μοντέλου 3D.", "partial": "Δεν ήταν δυνατή η ανάγνωση ορισμένων τμημάτων του μοντέλου 3D (παραλείφθηκαν {{n}}).", "viewerFailed": "Δεν ήταν δυνατή η εκκίνηση της προβολής 3D." + }, + "moveInference": { + "onAxisX": "Στον άξονα X", + "onAxisY": "Στον άξονα Y", + "fromPoint": "Από σημείο", + "extension": "Σε προέκταση", + "parallel": "Παράλληλα", + "perpendicular": "Κάθετα", + "intersection": "Τομή", + "polar": "Πολικό", + "locked": "Κλειδωμένο" } } diff --git a/open-pdf-studio/js/i18n/locales/el/preferences.json b/open-pdf-studio/js/i18n/locales/el/preferences.json index 45e4b2458..f2922cf03 100644 --- a/open-pdf-studio/js/i18n/locales/el/preferences.json +++ b/open-pdf-studio/js/i18n/locales/el/preferences.json @@ -166,6 +166,11 @@ "deletion": "Διαγραφή", "confirmBeforeDeleting": "Επιβεβαίωση πριν τη διαγραφή σχολιασμών", "snapToPdfContent": "Κούμπωμα στο περιεχόμενο σχεδίου PDF", + "moveInference": "Βοηθητικές γραμμές κατά τη μετακίνηση (G / mv)", + "moveInferenceTracking": "Γραμμές ιχνηλάτησης (ευθυγράμμιση με σημεία άλλης γεωμετρίας)", + "moveInferenceExtension": "Προεκτάσεις γραμμών και όψεων τοίχων", + "moveInferenceParallel": "Παράλληλα / κάθετα σε κοντινές γραμμές", + "moveInferenceHint": "Κρατήστε για λίγο τον δείκτη πάνω σε ένα σημείο για να το αποκτήσετε, κρατήστε πατημένο το Shift για να κλειδώσετε έναν οδηγό, το Tab εναλλάσσει απόσταση και γωνία. Οι πολικοί οδηγοί ακολουθούν τις ρυθμίσεις πολικής ιχνηλάτησης.", "navigation": "Πλοήγηση", "wheelZoomWithoutCtrl": "Ζουμ με τον τροχό του ποντικιού (χωρίς να κρατάτε το Ctrl)", "middleDoubleClickFit": "Διπλό κλικ με το μεσαίο κουμπί του ποντικιού για προσαρμογή σελίδας", diff --git a/open-pdf-studio/js/i18n/locales/en/common.json b/open-pdf-studio/js/i18n/locales/en/common.json index fff3840d8..bcc123891 100644 --- a/open-pdf-studio/js/i18n/locales/en/common.json +++ b/open-pdf-studio/js/i18n/locales/en/common.json @@ -307,5 +307,16 @@ "readFailed": "The 3D model could not be read.", "partial": "Some parts of the 3D model could not be read ({{n}} skipped).", "viewerFailed": "The 3D view could not be started." + }, + "moveInference": { + "onAxisX": "On X axis", + "onAxisY": "On Y axis", + "fromPoint": "From point", + "extension": "On extension", + "parallel": "Parallel", + "perpendicular": "Perpendicular", + "intersection": "Intersection", + "polar": "Polar", + "locked": "Locked" } } diff --git a/open-pdf-studio/js/i18n/locales/en/preferences.json b/open-pdf-studio/js/i18n/locales/en/preferences.json index f5df84d39..2a18c5db8 100644 --- a/open-pdf-studio/js/i18n/locales/en/preferences.json +++ b/open-pdf-studio/js/i18n/locales/en/preferences.json @@ -195,6 +195,11 @@ "snapToNearest": "Snap to Nearest", "objectSnapRadius": "Snap Radius (pixels)", "snapToPdfContent": "Snap to PDF Drawing Content", + "moveInference": "Move inference (G / mv)", + "moveInferenceTracking": "Tracking lines (align with points of other geometry)", + "moveInferenceExtension": "Extension lines of lines and wall faces", + "moveInferenceParallel": "Parallel / perpendicular to nearby lines", + "moveInferenceHint": "Hover a point briefly to acquire it, hold Shift to lock a guide, Tab switches between distance and angle. Polar guides follow the polar tracking settings.", "creation": "Creation", "autoSelectAfterCreation": "Auto-select annotation after creation", "deletion": "Deletion", diff --git a/open-pdf-studio/js/i18n/locales/es/common.json b/open-pdf-studio/js/i18n/locales/es/common.json index 7578051c6..b1d4db0da 100644 --- a/open-pdf-studio/js/i18n/locales/es/common.json +++ b/open-pdf-studio/js/i18n/locales/es/common.json @@ -303,5 +303,16 @@ "readFailed": "No se pudo leer el modelo 3D.", "partial": "No se pudieron leer algunas partes del modelo 3D ({{n}} omitidas).", "viewerFailed": "No se pudo iniciar la vista 3D." + }, + "moveInference": { + "onAxisX": "En el eje X", + "onAxisY": "En el eje Y", + "fromPoint": "Desde punto", + "extension": "En prolongación", + "parallel": "Paralelo", + "perpendicular": "Perpendicular", + "intersection": "Intersección", + "polar": "Polar", + "locked": "Bloqueado" } } diff --git a/open-pdf-studio/js/i18n/locales/es/preferences.json b/open-pdf-studio/js/i18n/locales/es/preferences.json index 76590b096..3cf6386c6 100644 --- a/open-pdf-studio/js/i18n/locales/es/preferences.json +++ b/open-pdf-studio/js/i18n/locales/es/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Ajustar al más cercano", "objectSnapRadius": "Radio de ajuste (píxeles)", "snapToPdfContent": "Ajustar al contenido del dibujo PDF", + "moveInference": "Inferencia al mover (G / mv)", + "moveInferenceTracking": "Líneas de rastreo (alinear con puntos de otra geometría)", + "moveInferenceExtension": "Prolongaciones de líneas y caras de muros", + "moveInferenceParallel": "Paralelo / perpendicular a líneas cercanas", + "moveInferenceHint": "Sitúe el cursor un momento sobre un punto para adquirirlo, mantenga Mayús para bloquear una guía, Tab alterna entre distancia y ángulo. Las guías polares siguen la configuración del rastreo polar.", "creation": "Creación", "autoSelectAfterCreation": "Seleccionar automáticamente la anotación después de crearla", "deletion": "Eliminación", diff --git a/open-pdf-studio/js/i18n/locales/fa/common.json b/open-pdf-studio/js/i18n/locales/fa/common.json index 156245fd1..d8879ecbe 100644 --- a/open-pdf-studio/js/i18n/locales/fa/common.json +++ b/open-pdf-studio/js/i18n/locales/fa/common.json @@ -303,5 +303,16 @@ "readFailed": "مدل 3D خوانده نشد.", "partial": "برخی بخش‌های مدل 3D خوانده نشد ({{n}} مورد رد شد).", "viewerFailed": "نمای 3D راه‌اندازی نشد." + }, + "moveInference": { + "onAxisX": "روی محور X", + "onAxisY": "روی محور Y", + "fromPoint": "از نقطه", + "extension": "روی امتداد", + "parallel": "موازی", + "perpendicular": "عمود", + "intersection": "تقاطع", + "polar": "قطبی", + "locked": "قفل‌شده" } } diff --git a/open-pdf-studio/js/i18n/locales/fa/preferences.json b/open-pdf-studio/js/i18n/locales/fa/preferences.json index 4a75d93aa..bcc8fe9f1 100644 --- a/open-pdf-studio/js/i18n/locales/fa/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fa/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "چسبندگی به نزدیک‌ترین", "objectSnapRadius": "شعاع چسبندگی (پیکسل)", "snapToPdfContent": "چسبندگی به محتوای نقشه PDF", + "moveInference": "خطوط راهنما هنگام جابه‌جایی (G / mv)", + "moveInferenceTracking": "خطوط ردیابی (هم‌ترازی با نقاط هندسه‌های دیگر)", + "moveInferenceExtension": "امتداد خطوط و وجه‌های دیوار", + "moveInferenceParallel": "موازی / عمود بر خطوط نزدیک", + "moveInferenceHint": "برای گرفتن یک نقطه، نشانگر را لحظه‌ای روی آن نگه دارید، برای قفل کردن یک راهنما Shift را نگه دارید، Tab بین فاصله و زاویه جابه‌جا می‌شود. راهنماهای قطبی از تنظیمات ردیابی قطبی پیروی می‌کنند.", "creation": "ایجاد", "autoSelectAfterCreation": "انتخاب خودکار حاشیه‌نویسی پس از ایجاد", "deletion": "حذف", diff --git a/open-pdf-studio/js/i18n/locales/fi/common.json b/open-pdf-studio/js/i18n/locales/fi/common.json index aaf3edf59..750a1fdc9 100644 --- a/open-pdf-studio/js/i18n/locales/fi/common.json +++ b/open-pdf-studio/js/i18n/locales/fi/common.json @@ -303,5 +303,16 @@ "readFailed": "3D-mallia ei voitu lukea.", "partial": "Joitakin 3D-mallin osia ei voitu lukea ({{n}} ohitettu).", "viewerFailed": "3D-näkymää ei voitu käynnistää." + }, + "moveInference": { + "onAxisX": "X-akselilla", + "onAxisY": "Y-akselilla", + "fromPoint": "Pisteestä", + "extension": "Jatkeella", + "parallel": "Yhdensuuntainen", + "perpendicular": "Kohtisuora", + "intersection": "Leikkauspiste", + "polar": "Napa", + "locked": "Lukittu" } } diff --git a/open-pdf-studio/js/i18n/locales/fi/preferences.json b/open-pdf-studio/js/i18n/locales/fi/preferences.json index f71e6c18d..03db3c107 100644 --- a/open-pdf-studio/js/i18n/locales/fi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fi/preferences.json @@ -166,6 +166,11 @@ "deletion": "Poistaminen", "confirmBeforeDeleting": "Vahvista ennen merkintöjen poistamista", "snapToPdfContent": "Kiinnitä PDF-piirustuksen sisältöön", + "moveInference": "Apuviivat siirrettäessä (G / mv)", + "moveInferenceTracking": "Seurantaviivat (tasaus muun geometrian pisteisiin)", + "moveInferenceExtension": "Viivojen ja seinäpintojen jatkeet", + "moveInferenceParallel": "Yhdensuuntainen / kohtisuora lähellä oleviin viivoihin", + "moveInferenceHint": "Pidä osoitinta hetki pisteen päällä poimiaksesi sen, pidä Shift painettuna lukitaksesi apuviivan, Tab vaihtaa etäisyyden ja kulman välillä. Napa-apuviivat noudattavat napaseurannan asetuksia.", "navigation": "Siirtyminen", "wheelZoomWithoutCtrl": "Zoomaa hiiren rullalla (ilman Ctrl-näppäintä)", "middleDoubleClickFit": "Hiiren keskipainikkeen kaksoisnapsautus sovittaa sivun", diff --git a/open-pdf-studio/js/i18n/locales/fr/common.json b/open-pdf-studio/js/i18n/locales/fr/common.json index 483d0a25e..45e7b2092 100644 --- a/open-pdf-studio/js/i18n/locales/fr/common.json +++ b/open-pdf-studio/js/i18n/locales/fr/common.json @@ -303,5 +303,16 @@ "readFailed": "Impossible de lire le modèle 3D.", "partial": "Certaines parties du modèle 3D n'ont pas pu être lues ({{n}} ignorées).", "viewerFailed": "Impossible de démarrer la vue 3D." + }, + "moveInference": { + "onAxisX": "Sur l’axe X", + "onAxisY": "Sur l’axe Y", + "fromPoint": "Depuis le point", + "extension": "Sur le prolongement", + "parallel": "Parallèle", + "perpendicular": "Perpendiculaire", + "intersection": "Intersection", + "polar": "Polaire", + "locked": "Verrouillé" } } diff --git a/open-pdf-studio/js/i18n/locales/fr/preferences.json b/open-pdf-studio/js/i18n/locales/fr/preferences.json index a069f6472..5e03be1ac 100644 --- a/open-pdf-studio/js/i18n/locales/fr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fr/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Accrocher au point le plus proche", "objectSnapRadius": "Rayon d'accrochage (pixels)", "snapToPdfContent": "Accrocher au contenu du dessin PDF", + "moveInference": "Inférence au déplacement (G / mv)", + "moveInferenceTracking": "Lignes de repérage (alignement sur les points d’autres géométries)", + "moveInferenceExtension": "Prolongements des lignes et des faces de murs", + "moveInferenceParallel": "Parallèle / perpendiculaire aux lignes voisines", + "moveInferenceHint": "Survolez brièvement un point pour l’acquérir, maintenez Maj pour verrouiller un guide, Tab bascule entre distance et angle. Les guides polaires suivent les réglages du repérage polaire.", "creation": "Création", "autoSelectAfterCreation": "Sélectionner automatiquement l'annotation après création", "deletion": "Suppression", diff --git a/open-pdf-studio/js/i18n/locales/he/common.json b/open-pdf-studio/js/i18n/locales/he/common.json index 0cfc312b1..2d456e185 100644 --- a/open-pdf-studio/js/i18n/locales/he/common.json +++ b/open-pdf-studio/js/i18n/locales/he/common.json @@ -303,5 +303,16 @@ "readFailed": "לא ניתן לקרוא את מודל ה-3D.", "partial": "לא ניתן לקרוא חלקים מסוימים של מודל ה-3D ({{n}} דולגו).", "viewerFailed": "לא ניתן להפעיל את תצוגת ה-3D." + }, + "moveInference": { + "onAxisX": "על ציר X", + "onAxisY": "על ציר Y", + "fromPoint": "מנקודה", + "extension": "על ההמשך", + "parallel": "מקביל", + "perpendicular": "ניצב", + "intersection": "חיתוך", + "polar": "קוטבי", + "locked": "נעול" } } diff --git a/open-pdf-studio/js/i18n/locales/he/preferences.json b/open-pdf-studio/js/i18n/locales/he/preferences.json index d9fa1ffe5..70f1f8e1d 100644 --- a/open-pdf-studio/js/i18n/locales/he/preferences.json +++ b/open-pdf-studio/js/i18n/locales/he/preferences.json @@ -166,6 +166,11 @@ "deletion": "מחיקה", "confirmBeforeDeleting": "אישור לפני מחיקת הערות", "snapToPdfContent": "הצמדה לתוכן השרטוט ב-PDF", + "moveInference": "קווי עזר בזמן הזזה (G / mv)", + "moveInferenceTracking": "קווי מעקב (יישור לנקודות של גאומטריה אחרת)", + "moveInferenceExtension": "המשכים של קווים ושל פני קירות", + "moveInferenceParallel": "מקביל / ניצב לקווים סמוכים", + "moveInferenceHint": "השהו את הסמן לרגע מעל נקודה כדי ללכוד אותה, החזיקו Shift כדי לנעול קו עזר, Tab עובר בין מרחק לזווית. קווי העזר הקוטביים פועלים לפי הגדרות המעקב הקוטבי.", "navigation": "ניווט", "wheelZoomWithoutCtrl": "זום בגלגלת העכבר (ללא החזקת Ctrl)", "middleDoubleClickFit": "לחיצה כפולה על לחצן העכבר האמצעי להתאמת העמוד", diff --git a/open-pdf-studio/js/i18n/locales/hi/common.json b/open-pdf-studio/js/i18n/locales/hi/common.json index 57d697661..f06c07c3c 100644 --- a/open-pdf-studio/js/i18n/locales/hi/common.json +++ b/open-pdf-studio/js/i18n/locales/hi/common.json @@ -303,5 +303,16 @@ "readFailed": "3D मॉडल पढ़ा नहीं जा सका।", "partial": "3D मॉडल के कुछ भाग पढ़े नहीं जा सके ({{n}} छोड़े गए)।", "viewerFailed": "3D दृश्य शुरू नहीं किया जा सका।" + }, + "moveInference": { + "onAxisX": "X अक्ष पर", + "onAxisY": "Y अक्ष पर", + "fromPoint": "बिंदु से", + "extension": "विस्तार पर", + "parallel": "समांतर", + "perpendicular": "लंबवत", + "intersection": "प्रतिच्छेदन", + "polar": "ध्रुवीय", + "locked": "लॉक" } } diff --git a/open-pdf-studio/js/i18n/locales/hi/preferences.json b/open-pdf-studio/js/i18n/locales/hi/preferences.json index 775daa6fe..a5274ba21 100644 --- a/open-pdf-studio/js/i18n/locales/hi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hi/preferences.json @@ -166,6 +166,11 @@ "deletion": "हटाना", "confirmBeforeDeleting": "एनोटेशन हटाने से पहले पुष्टि करें", "snapToPdfContent": "PDF ड्रॉइंग सामग्री पर स्नैप करें", + "moveInference": "खिसकाते समय सहायक रेखाएँ (G / mv)", + "moveInferenceTracking": "ट्रैकिंग रेखाएँ (अन्य ज्यामिति के बिंदुओं से संरेखण)", + "moveInferenceExtension": "रेखाओं और दीवार सतहों का विस्तार", + "moveInferenceParallel": "पास की रेखाओं के समांतर / लंबवत", + "moveInferenceHint": "किसी बिंदु को पकड़ने के लिए कर्सर को कुछ क्षण उस पर रखें, किसी गाइड को लॉक करने के लिए Shift दबाए रखें, Tab दूरी और कोण के बीच बदलता है। ध्रुवीय गाइड ध्रुवीय ट्रैकिंग की सेटिंग्स का पालन करते हैं।", "navigation": "नेविगेशन", "wheelZoomWithoutCtrl": "माउस व्हील से ज़ूम करें (Ctrl दबाए बिना)", "middleDoubleClickFit": "पृष्ठ फ़िट करने के लिए माउस के बीच वाले बटन से डबल-क्लिक करें", diff --git a/open-pdf-studio/js/i18n/locales/hr/common.json b/open-pdf-studio/js/i18n/locales/hr/common.json index 61f8a489a..cf8009cee 100644 --- a/open-pdf-studio/js/i18n/locales/hr/common.json +++ b/open-pdf-studio/js/i18n/locales/hr/common.json @@ -303,5 +303,16 @@ "readFailed": "3D model nije moguće pročitati.", "partial": "Neke dijelove 3D modela nije moguće pročitati (preskočeno: {{n}}).", "viewerFailed": "3D prikaz nije moguće pokrenuti." + }, + "moveInference": { + "onAxisX": "Na osi X", + "onAxisY": "Na osi Y", + "fromPoint": "Od točke", + "extension": "Na produžetku", + "parallel": "Paralelno", + "perpendicular": "Okomito", + "intersection": "Sjecište", + "polar": "Polarno", + "locked": "Zaključano" } } diff --git a/open-pdf-studio/js/i18n/locales/hr/preferences.json b/open-pdf-studio/js/i18n/locales/hr/preferences.json index 7099564b5..5d6e1a906 100644 --- a/open-pdf-studio/js/i18n/locales/hr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hr/preferences.json @@ -166,6 +166,11 @@ "deletion": "Brisanje", "confirmBeforeDeleting": "Potvrdi prije brisanja oznaka", "snapToPdfContent": "Privlačenje na sadržaj PDF crteža", + "moveInference": "Pomoćne linije pri pomicanju (G / mv)", + "moveInferenceTracking": "Linije praćenja (poravnanje s točkama druge geometrije)", + "moveInferenceExtension": "Produžeci linija i lica zidova", + "moveInferenceParallel": "Paralelno / okomito na obližnje linije", + "moveInferenceHint": "Zadržite pokazivač kratko iznad točke da je uhvatite, držite Shift da zaključate pomoćnu liniju, Tab prebacuje između udaljenosti i kuta. Polarne pomoćne linije slijede postavke polarnog praćenja.", "navigation": "Navigacija", "wheelZoomWithoutCtrl": "Zumiranje kotačićem miša (bez držanja tipke Ctrl)", "middleDoubleClickFit": "Dvostruki klik srednjom tipkom miša prilagođava stranicu", diff --git a/open-pdf-studio/js/i18n/locales/hu/common.json b/open-pdf-studio/js/i18n/locales/hu/common.json index b9a3d9979..9b0f44530 100644 --- a/open-pdf-studio/js/i18n/locales/hu/common.json +++ b/open-pdf-studio/js/i18n/locales/hu/common.json @@ -303,5 +303,16 @@ "readFailed": "A 3D modell nem olvasható be.", "partial": "A 3D modell egyes részei nem olvashatók be ({{n}} kihagyva).", "viewerFailed": "A 3D nézet nem indítható el." + }, + "moveInference": { + "onAxisX": "Az X tengelyen", + "onAxisY": "Az Y tengelyen", + "fromPoint": "Pontból", + "extension": "Meghosszabbításon", + "parallel": "Párhuzamos", + "perpendicular": "Merőleges", + "intersection": "Metszéspont", + "polar": "Poláris", + "locked": "Rögzítve" } } diff --git a/open-pdf-studio/js/i18n/locales/hu/preferences.json b/open-pdf-studio/js/i18n/locales/hu/preferences.json index 58e64d2c6..edf0029a1 100644 --- a/open-pdf-studio/js/i18n/locales/hu/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hu/preferences.json @@ -166,6 +166,11 @@ "deletion": "Törlés", "confirmBeforeDeleting": "Megerősítés annotációk törlése előtt", "snapToPdfContent": "Bekötés a PDF-rajz tartalmához", + "moveInference": "Segédvonalak mozgatáskor (G / mv)", + "moveInferenceTracking": "Követővonalak (igazítás más geometria pontjaihoz)", + "moveInferenceExtension": "Vonalak és falsíkok meghosszabbítása", + "moveInferenceParallel": "Párhuzamos / merőleges a közeli vonalakra", + "moveInferenceHint": "Tartsa a kurzort röviden egy ponton a felvételéhez, tartsa lenyomva a Shift billentyűt egy segédvonal rögzítéséhez, a Tab vált a távolság és a szög között. A poláris segédvonalak a poláris követés beállításait követik.", "navigation": "Navigáció", "wheelZoomWithoutCtrl": "Nagyítás egérgörgővel (Ctrl lenyomása nélkül)", "middleDoubleClickFit": "Dupla kattintás a középső egérgombbal az oldal illesztéséhez", diff --git a/open-pdf-studio/js/i18n/locales/id/common.json b/open-pdf-studio/js/i18n/locales/id/common.json index 828695bf0..c4740f36b 100644 --- a/open-pdf-studio/js/i18n/locales/id/common.json +++ b/open-pdf-studio/js/i18n/locales/id/common.json @@ -303,5 +303,16 @@ "readFailed": "Model 3D tidak dapat dibaca.", "partial": "Beberapa bagian model 3D tidak dapat dibaca ({{n}} dilewati).", "viewerFailed": "Tampilan 3D tidak dapat dimulai." + }, + "moveInference": { + "onAxisX": "Pada sumbu X", + "onAxisY": "Pada sumbu Y", + "fromPoint": "Dari titik", + "extension": "Pada perpanjangan", + "parallel": "Sejajar", + "perpendicular": "Tegak lurus", + "intersection": "Perpotongan", + "polar": "Polar", + "locked": "Terkunci" } } diff --git a/open-pdf-studio/js/i18n/locales/id/preferences.json b/open-pdf-studio/js/i18n/locales/id/preferences.json index e59427002..875d052b8 100644 --- a/open-pdf-studio/js/i18n/locales/id/preferences.json +++ b/open-pdf-studio/js/i18n/locales/id/preferences.json @@ -166,6 +166,11 @@ "deletion": "Penghapusan", "confirmBeforeDeleting": "Konfirmasi sebelum menghapus anotasi", "snapToPdfContent": "Snap ke Konten Gambar PDF", + "moveInference": "Inferensi saat memindahkan (G / mv)", + "moveInferenceTracking": "Garis pelacakan (sejajarkan dengan titik geometri lain)", + "moveInferenceExtension": "Perpanjangan garis dan muka dinding", + "moveInferenceParallel": "Sejajar / tegak lurus terhadap garis di dekatnya", + "moveInferenceHint": "Arahkan kursor sebentar pada sebuah titik untuk menangkapnya, tahan Shift untuk mengunci garis bantu, Tab beralih antara jarak dan sudut. Garis bantu polar mengikuti pengaturan pelacakan polar.", "navigation": "Navigasi", "wheelZoomWithoutCtrl": "Zoom dengan roda mouse (tanpa menahan Ctrl)", "middleDoubleClickFit": "Klik dua kali tombol tengah mouse untuk menyesuaikan halaman", diff --git a/open-pdf-studio/js/i18n/locales/it/common.json b/open-pdf-studio/js/i18n/locales/it/common.json index c71d8fcd9..40b89618a 100644 --- a/open-pdf-studio/js/i18n/locales/it/common.json +++ b/open-pdf-studio/js/i18n/locales/it/common.json @@ -303,5 +303,16 @@ "readFailed": "Impossibile leggere il modello 3D.", "partial": "Impossibile leggere alcune parti del modello 3D ({{n}} saltate).", "viewerFailed": "Impossibile avviare la vista 3D." + }, + "moveInference": { + "onAxisX": "Sull’asse X", + "onAxisY": "Sull’asse Y", + "fromPoint": "Dal punto", + "extension": "Sull’estensione", + "parallel": "Parallelo", + "perpendicular": "Perpendicolare", + "intersection": "Intersezione", + "polar": "Polare", + "locked": "Bloccato" } } diff --git a/open-pdf-studio/js/i18n/locales/it/preferences.json b/open-pdf-studio/js/i18n/locales/it/preferences.json index 78a0b4746..93e644055 100644 --- a/open-pdf-studio/js/i18n/locales/it/preferences.json +++ b/open-pdf-studio/js/i18n/locales/it/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Aggancia al più vicino", "objectSnapRadius": "Raggio di aggancio (pixel)", "snapToPdfContent": "Aggancia al contenuto del disegno PDF", + "moveInference": "Inferenza durante lo spostamento (G / mv)", + "moveInferenceTracking": "Linee di puntamento (allinea ai punti di altre geometrie)", + "moveInferenceExtension": "Estensioni di linee e facce di muri", + "moveInferenceParallel": "Parallelo / perpendicolare a linee vicine", + "moveInferenceHint": "Soffermati su un punto per acquisirlo, tieni premuto Maiusc per bloccare una guida, Tab passa tra distanza e angolo. Le guide polari seguono le impostazioni del puntamento polare.", "creation": "Creazione", "autoSelectAfterCreation": "Seleziona automaticamente l'annotazione dopo la creazione", "deletion": "Eliminazione", diff --git a/open-pdf-studio/js/i18n/locales/ja/common.json b/open-pdf-studio/js/i18n/locales/ja/common.json index cefff1f8f..307056a93 100644 --- a/open-pdf-studio/js/i18n/locales/ja/common.json +++ b/open-pdf-studio/js/i18n/locales/ja/common.json @@ -303,5 +303,16 @@ "readFailed": "3D モデルを読み取れませんでした。", "partial": "3D モデルの一部を読み取れませんでした ({{n}} 件をスキップ)。", "viewerFailed": "3D ビューを開始できませんでした。" + }, + "moveInference": { + "onAxisX": "X 軸上", + "onAxisY": "Y 軸上", + "fromPoint": "点から", + "extension": "延長線上", + "parallel": "平行", + "perpendicular": "垂直", + "intersection": "交点", + "polar": "極", + "locked": "ロック" } } diff --git a/open-pdf-studio/js/i18n/locales/ja/preferences.json b/open-pdf-studio/js/i18n/locales/ja/preferences.json index d06597723..8e6183593 100644 --- a/open-pdf-studio/js/i18n/locales/ja/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ja/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "近接点にスナップ", "objectSnapRadius": "スナップ半径(ピクセル)", "snapToPdfContent": "PDF の図形内容にスナップ", + "moveInference": "移動時の推論ガイド (G / mv)", + "moveInferenceTracking": "トラッキング線 (他の図形の点に位置合わせ)", + "moveInferenceExtension": "線と壁面の延長線", + "moveInferenceParallel": "近くの線に平行 / 垂直", + "moveInferenceHint": "点の上にカーソルを少し置くと取得され、Shift を押し続けるとガイドがロックされ、Tab で距離と角度を切り替えます。極ガイドは極トラッキングの設定に従います。", "creation": "作成", "autoSelectAfterCreation": "作成後に注釈を自動選択する", "deletion": "削除", diff --git a/open-pdf-studio/js/i18n/locales/ko/common.json b/open-pdf-studio/js/i18n/locales/ko/common.json index 0c4c786da..d7f575cee 100644 --- a/open-pdf-studio/js/i18n/locales/ko/common.json +++ b/open-pdf-studio/js/i18n/locales/ko/common.json @@ -303,5 +303,16 @@ "readFailed": "3D 모델을 읽을 수 없습니다.", "partial": "3D 모델의 일부를 읽을 수 없습니다({{n}}개 건너뜀).", "viewerFailed": "3D 보기를 시작할 수 없습니다." + }, + "moveInference": { + "onAxisX": "X축 위", + "onAxisY": "Y축 위", + "fromPoint": "점에서", + "extension": "연장선 위", + "parallel": "평행", + "perpendicular": "수직", + "intersection": "교차점", + "polar": "극좌표", + "locked": "고정됨" } } diff --git a/open-pdf-studio/js/i18n/locales/ko/preferences.json b/open-pdf-studio/js/i18n/locales/ko/preferences.json index 7e25027c7..b68a7a771 100644 --- a/open-pdf-studio/js/i18n/locales/ko/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ko/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "근처점에 스냅", "objectSnapRadius": "스냅 반경(픽셀)", "snapToPdfContent": "PDF 도면 내용에 스냅", + "moveInference": "이동 시 추론 안내선 (G / mv)", + "moveInferenceTracking": "추적선 (다른 도형의 점에 정렬)", + "moveInferenceExtension": "선과 벽면의 연장선", + "moveInferenceParallel": "가까운 선에 평행 / 수직", + "moveInferenceHint": "점 위에 커서를 잠시 두면 점을 획득하고, Shift를 누르고 있으면 안내선이 고정되며, Tab으로 거리와 각도를 전환합니다. 극좌표 안내선은 극좌표 추적 설정을 따릅니다.", "creation": "생성", "autoSelectAfterCreation": "생성 후 주석 자동 선택", "deletion": "삭제", diff --git a/open-pdf-studio/js/i18n/locales/lo/common.json b/open-pdf-studio/js/i18n/locales/lo/common.json index b816990ed..bbedcfb28 100644 --- a/open-pdf-studio/js/i18n/locales/lo/common.json +++ b/open-pdf-studio/js/i18n/locales/lo/common.json @@ -303,5 +303,16 @@ "readFailed": "ບໍ່ສາມາດອ່ານແບບຈຳລອງ 3D ໄດ້.", "partial": "ບໍ່ສາມາດອ່ານບາງສ່ວນຂອງແບບຈຳລອງ 3D ໄດ້ (ຂ້າມ {{n}}).", "viewerFailed": "ບໍ່ສາມາດເລີ່ມມຸມມອງ 3D ໄດ້." + }, + "moveInference": { + "onAxisX": "ເທິງແກນ X", + "onAxisY": "ເທິງແກນ Y", + "fromPoint": "ຈາກຈຸດ", + "extension": "ເທິງສ່ວນຕໍ່ຂະຫຍາຍ", + "parallel": "ຂະໜານ", + "perpendicular": "ຕັ້ງສາກ", + "intersection": "ຈຸດຕັດກັນ", + "polar": "ແບບຂົ້ວ", + "locked": "ລັອກແລ້ວ" } } diff --git a/open-pdf-studio/js/i18n/locales/lo/preferences.json b/open-pdf-studio/js/i18n/locales/lo/preferences.json index 886f09f3d..80a7bc2de 100644 --- a/open-pdf-studio/js/i18n/locales/lo/preferences.json +++ b/open-pdf-studio/js/i18n/locales/lo/preferences.json @@ -191,6 +191,11 @@ "snapToNearest": "ດຶງຕິດຈຸດທີ່ໃກ້ທີ່ສຸດ", "objectSnapRadius": "ລັດສະໝີການດຶງດູດ (pixels)", "snapToPdfContent": "ດຶງຕິດເນື້ອຫາເສັ້ນແຕ້ມໃນ PDF", + "moveInference": "ເສັ້ນຊ່ວຍຂະນະຍ້າຍ (G / mv)", + "moveInferenceTracking": "ເສັ້ນຕິດຕາມ (ຈັດແນວກັບຈຸດຂອງຮູບຊົງອື່ນ)", + "moveInferenceExtension": "ສ່ວນຕໍ່ຂະຫຍາຍຂອງເສັ້ນ ແລະ ໜ້າຝາ", + "moveInferenceParallel": "ຂະໜານ / ຕັ້ງສາກກັບເສັ້ນທີ່ຢູ່ໃກ້", + "moveInferenceHint": "ວາງເຄີເຊີໄວ້ເທິງຈຸດໜຶ່ງຄາວໜຶ່ງເພື່ອຈັບຈຸດນັ້ນ, ກົດ Shift ຄ້າງໄວ້ເພື່ອລັອກເສັ້ນຊ່ວຍ, Tab ສະຫຼັບລະຫວ່າງໄລຍະທາງ ແລະ ມຸມ. ເສັ້ນຊ່ວຍແບບຂົ້ວປະຕິບັດຕາມການຕັ້ງຄ່າການກຳນົດທິດທາງມຸມ.", "creation": "ການສ້າງ", "autoSelectAfterCreation": "ເລືອກຄຳອະທິບາຍປະກອບໂດຍອັດຕະໂນມັດຫຼັງຈາກສ້າງ", "deletion": "ການລຶບ", diff --git a/open-pdf-studio/js/i18n/locales/ms/common.json b/open-pdf-studio/js/i18n/locales/ms/common.json index 752aeb020..f88bab07a 100644 --- a/open-pdf-studio/js/i18n/locales/ms/common.json +++ b/open-pdf-studio/js/i18n/locales/ms/common.json @@ -303,5 +303,16 @@ "readFailed": "Model 3D tidak dapat dibaca.", "partial": "Sesetengah bahagian model 3D tidak dapat dibaca ({{n}} dilangkau).", "viewerFailed": "Paparan 3D tidak dapat dimulakan." + }, + "moveInference": { + "onAxisX": "Pada paksi X", + "onAxisY": "Pada paksi Y", + "fromPoint": "Dari titik", + "extension": "Pada lanjutan", + "parallel": "Selari", + "perpendicular": "Serenjang", + "intersection": "Persilangan", + "polar": "Kutub", + "locked": "Dikunci" } } diff --git a/open-pdf-studio/js/i18n/locales/ms/preferences.json b/open-pdf-studio/js/i18n/locales/ms/preferences.json index 58307fd14..8299940c4 100644 --- a/open-pdf-studio/js/i18n/locales/ms/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ms/preferences.json @@ -166,6 +166,11 @@ "deletion": "Pemadaman", "confirmBeforeDeleting": "Sahkan sebelum memadam anotasi", "snapToPdfContent": "Rakam ke Kandungan Lukisan PDF", + "moveInference": "Inferens semasa mengalih (G / mv)", + "moveInferenceTracking": "Garisan penjejakan (jajarkan dengan titik geometri lain)", + "moveInferenceExtension": "Lanjutan garisan dan muka dinding", + "moveInferenceParallel": "Selari / serenjang dengan garisan berhampiran", + "moveInferenceHint": "Letakkan kursor seketika pada satu titik untuk menangkapnya, tahan Shift untuk mengunci garisan panduan, Tab bertukar antara jarak dan sudut. Garisan panduan polar mengikut tetapan penjejakan polar.", "navigation": "Navigasi", "wheelZoomWithoutCtrl": "Zum dengan roda tetikus (tanpa menahan Ctrl)", "middleDoubleClickFit": "Klik dua kali butang tengah tetikus untuk muat halaman", diff --git a/open-pdf-studio/js/i18n/locales/nb/common.json b/open-pdf-studio/js/i18n/locales/nb/common.json index dec55260b..9810c3a03 100644 --- a/open-pdf-studio/js/i18n/locales/nb/common.json +++ b/open-pdf-studio/js/i18n/locales/nb/common.json @@ -303,5 +303,16 @@ "readFailed": "3D-modellen kunne ikke leses.", "partial": "Noen deler av 3D-modellen kunne ikke leses ({{n}} hoppet over).", "viewerFailed": "3D-visningen kunne ikke startes." + }, + "moveInference": { + "onAxisX": "På X-aksen", + "onAxisY": "På Y-aksen", + "fromPoint": "Fra punkt", + "extension": "På forlengelse", + "parallel": "Parallell", + "perpendicular": "Vinkelrett", + "intersection": "Skjæringspunkt", + "polar": "Polar", + "locked": "Låst" } } diff --git a/open-pdf-studio/js/i18n/locales/nb/preferences.json b/open-pdf-studio/js/i18n/locales/nb/preferences.json index cf539b04e..d02f9e7c5 100644 --- a/open-pdf-studio/js/i18n/locales/nb/preferences.json +++ b/open-pdf-studio/js/i18n/locales/nb/preferences.json @@ -166,6 +166,11 @@ "deletion": "Sletting", "confirmBeforeDeleting": "Bekreft før sletting av merknader", "snapToPdfContent": "Fest til PDF-tegningsinnhold", + "moveInference": "Hjelpelinjer ved flytting (G / mv)", + "moveInferenceTracking": "Sporingslinjer (juster etter punkter i annen geometri)", + "moveInferenceExtension": "Forlengelser av linjer og veggflater", + "moveInferenceParallel": "Parallell / vinkelrett på linjer i nærheten", + "moveInferenceHint": "Hold pekeren et øyeblikk over et punkt for å fange det, hold Shift nede for å låse en hjelpelinje, Tab bytter mellom avstand og vinkel. Polare hjelpelinjer følger innstillingene for polarsporing.", "navigation": "Navigasjon", "wheelZoomWithoutCtrl": "Zoom med musehjulet (uten å holde inne Ctrl)", "middleDoubleClickFit": "Dobbeltklikk med den midterste museknappen for å tilpasse siden", diff --git a/open-pdf-studio/js/i18n/locales/nl/common.json b/open-pdf-studio/js/i18n/locales/nl/common.json index 178d05b58..527fb61f7 100644 --- a/open-pdf-studio/js/i18n/locales/nl/common.json +++ b/open-pdf-studio/js/i18n/locales/nl/common.json @@ -307,5 +307,16 @@ "readFailed": "Het 3D-model kon niet worden gelezen.", "partial": "Een deel van het 3D-model kon niet worden gelezen ({{n}} overgeslagen).", "viewerFailed": "De 3D-weergave kon niet worden gestart." + }, + "moveInference": { + "onAxisX": "Op X-as", + "onAxisY": "Op Y-as", + "fromPoint": "Vanaf punt", + "extension": "Op verlengde", + "parallel": "Evenwijdig", + "perpendicular": "Loodrecht", + "intersection": "Snijpunt", + "polar": "Pool", + "locked": "Vastgezet" } } diff --git a/open-pdf-studio/js/i18n/locales/nl/preferences.json b/open-pdf-studio/js/i18n/locales/nl/preferences.json index 864de6e02..c6f468c75 100644 --- a/open-pdf-studio/js/i18n/locales/nl/preferences.json +++ b/open-pdf-studio/js/i18n/locales/nl/preferences.json @@ -170,6 +170,11 @@ "snapToNearest": "Uitlijnen op dichtstbijzijnde", "objectSnapRadius": "Uitlijnstraal (pixels)", "snapToPdfContent": "Uitlijnen op PDF-tekeninhoud", + "moveInference": "Hulplijnen bij verplaatsen (G / mv)", + "moveInferenceTracking": "Volglijnen (uitlijnen op punten van andere geometrie)", + "moveInferenceExtension": "Verlengden van lijnen en wandvlakken", + "moveInferenceParallel": "Evenwijdig / loodrecht op lijnen in de buurt", + "moveInferenceHint": "Houd de cursor even op een punt om het vast te pakken, houd Shift ingedrukt om een hulplijn vast te zetten, Tab wisselt tussen afstand en hoek. Poolhulplijnen volgen de instellingen van poolvolging.", "navigation": "Navigatie", "wheelZoomWithoutCtrl": "Zoomen met muiswiel (zonder Ctrl ingedrukt)", "middleDoubleClickFit": "Dubbelklikken met de middelste muisknop past de pagina in het venster", diff --git a/open-pdf-studio/js/i18n/locales/pl/common.json b/open-pdf-studio/js/i18n/locales/pl/common.json index 2317c4686..80d338095 100644 --- a/open-pdf-studio/js/i18n/locales/pl/common.json +++ b/open-pdf-studio/js/i18n/locales/pl/common.json @@ -303,5 +303,16 @@ "readFailed": "Nie można odczytać modelu 3D.", "partial": "Nie można odczytać niektórych części modelu 3D (pominięto: {{n}}).", "viewerFailed": "Nie można uruchomić widoku 3D." + }, + "moveInference": { + "onAxisX": "Na osi X", + "onAxisY": "Na osi Y", + "fromPoint": "Od punktu", + "extension": "Na przedłużeniu", + "parallel": "Równolegle", + "perpendicular": "Prostopadle", + "intersection": "Przecięcie", + "polar": "Biegunowe", + "locked": "Zablokowane" } } diff --git a/open-pdf-studio/js/i18n/locales/pl/preferences.json b/open-pdf-studio/js/i18n/locales/pl/preferences.json index efbec40ea..f2621262b 100644 --- a/open-pdf-studio/js/i18n/locales/pl/preferences.json +++ b/open-pdf-studio/js/i18n/locales/pl/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Przyciągaj do najbliższego", "objectSnapRadius": "Promień przyciągania (piksele)", "snapToPdfContent": "Przyciągaj do zawartości rysunku PDF", + "moveInference": "Wskazówki przy przesuwaniu (G / mv)", + "moveInferenceTracking": "Linie śledzenia (wyrównanie do punktów innej geometrii)", + "moveInferenceExtension": "Przedłużenia linii i lic ścian", + "moveInferenceParallel": "Równolegle / prostopadle do pobliskich linii", + "moveInferenceHint": "Przytrzymaj kursor chwilę nad punktem, aby go przechwycić, przytrzymaj Shift, aby zablokować linię pomocniczą, Tab przełącza między odległością a kątem. Linie biegunowe działają według ustawień śledzenia biegunowego.", "creation": "Tworzenie", "autoSelectAfterCreation": "Automatycznie zaznacz adnotację po utworzeniu", "deletion": "Usuwanie", diff --git a/open-pdf-studio/js/i18n/locales/pt/common.json b/open-pdf-studio/js/i18n/locales/pt/common.json index 93a6cda34..a2705f6e1 100644 --- a/open-pdf-studio/js/i18n/locales/pt/common.json +++ b/open-pdf-studio/js/i18n/locales/pt/common.json @@ -303,5 +303,16 @@ "readFailed": "Não foi possível ler o modelo 3D.", "partial": "Algumas partes do modelo 3D não puderam ser lidas ({{n}} ignoradas).", "viewerFailed": "Não foi possível iniciar a visualização 3D." + }, + "moveInference": { + "onAxisX": "No eixo X", + "onAxisY": "No eixo Y", + "fromPoint": "A partir do ponto", + "extension": "No prolongamento", + "parallel": "Paralelo", + "perpendicular": "Perpendicular", + "intersection": "Interseção", + "polar": "Polar", + "locked": "Bloqueado" } } diff --git a/open-pdf-studio/js/i18n/locales/pt/preferences.json b/open-pdf-studio/js/i18n/locales/pt/preferences.json index 5beb9c5f7..a834024bf 100644 --- a/open-pdf-studio/js/i18n/locales/pt/preferences.json +++ b/open-pdf-studio/js/i18n/locales/pt/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "Encaixar no mais próximo", "objectSnapRadius": "Raio de encaixe (pixels)", "snapToPdfContent": "Encaixar no conteúdo do desenho PDF", + "moveInference": "Inferência ao mover (G / mv)", + "moveInferenceTracking": "Linhas de rastreamento (alinhar com pontos de outra geometria)", + "moveInferenceExtension": "Prolongamentos de linhas e faces de paredes", + "moveInferenceParallel": "Paralelo / perpendicular a linhas próximas", + "moveInferenceHint": "Passe o cursor brevemente sobre um ponto para o adquirir, mantenha Shift premido para bloquear uma guia, Tab alterna entre distância e ângulo. As guias polares seguem as definições do rastreamento polar.", "creation": "Criação", "autoSelectAfterCreation": "Selecionar automaticamente a anotação após a criação", "deletion": "Exclusão", diff --git a/open-pdf-studio/js/i18n/locales/ro/common.json b/open-pdf-studio/js/i18n/locales/ro/common.json index 25350b525..de30896fc 100644 --- a/open-pdf-studio/js/i18n/locales/ro/common.json +++ b/open-pdf-studio/js/i18n/locales/ro/common.json @@ -303,5 +303,16 @@ "readFailed": "Modelul 3D nu a putut fi citit.", "partial": "Unele părți ale modelului 3D nu au putut fi citite ({{n}} omise).", "viewerFailed": "Vizualizarea 3D nu a putut fi pornită." + }, + "moveInference": { + "onAxisX": "Pe axa X", + "onAxisY": "Pe axa Y", + "fromPoint": "Din punct", + "extension": "Pe prelungire", + "parallel": "Paralel", + "perpendicular": "Perpendicular", + "intersection": "Intersecție", + "polar": "Polar", + "locked": "Blocat" } } diff --git a/open-pdf-studio/js/i18n/locales/ro/preferences.json b/open-pdf-studio/js/i18n/locales/ro/preferences.json index 851c661a4..ee1e50b8f 100644 --- a/open-pdf-studio/js/i18n/locales/ro/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ro/preferences.json @@ -166,6 +166,11 @@ "deletion": "Stergere", "confirmBeforeDeleting": "Confirmare inainte de stergerea adnotarilor", "snapToPdfContent": "Fixare la conținutul desenului PDF", + "moveInference": "Inferență la mutare (G / mv)", + "moveInferenceTracking": "Linii de urmărire (aliniere la punctele altor geometrii)", + "moveInferenceExtension": "Prelungiri ale liniilor și fețelor de pereți", + "moveInferenceParallel": "Paralel / perpendicular pe liniile din apropiere", + "moveInferenceHint": "Țineți scurt cursorul pe un punct pentru a-l prelua, țineți apăsat Shift pentru a bloca un ghidaj, Tab comută între distanță și unghi. Ghidajele polare urmează setările urmăririi polare.", "navigation": "Navigare", "wheelZoomWithoutCtrl": "Zoom cu rotița mouse-ului (fără a ține apăsat Ctrl)", "middleDoubleClickFit": "Dublu clic cu butonul din mijloc al mouse-ului pentru încadrarea paginii", diff --git a/open-pdf-studio/js/i18n/locales/ru/common.json b/open-pdf-studio/js/i18n/locales/ru/common.json index 42760f975..d919e935f 100644 --- a/open-pdf-studio/js/i18n/locales/ru/common.json +++ b/open-pdf-studio/js/i18n/locales/ru/common.json @@ -303,5 +303,16 @@ "readFailed": "Не удалось прочитать 3D-модель.", "partial": "Не удалось прочитать некоторые части 3D-модели (пропущено: {{n}}).", "viewerFailed": "Не удалось запустить 3D-вид." + }, + "moveInference": { + "onAxisX": "По оси X", + "onAxisY": "По оси Y", + "fromPoint": "От точки", + "extension": "На продолжении", + "parallel": "Параллельно", + "perpendicular": "Перпендикулярно", + "intersection": "Пересечение", + "polar": "Полярно", + "locked": "Зафиксировано" } } diff --git a/open-pdf-studio/js/i18n/locales/ru/preferences.json b/open-pdf-studio/js/i18n/locales/ru/preferences.json index d57f94559..2b9c0a3f8 100644 --- a/open-pdf-studio/js/i18n/locales/ru/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ru/preferences.json @@ -166,6 +166,11 @@ "deletion": "Удаление", "confirmBeforeDeleting": "Подтверждать перед удалением аннотаций", "snapToPdfContent": "Привязка к содержимому PDF-чертежа", + "moveInference": "Подсказки при перемещении (G / mv)", + "moveInferenceTracking": "Линии отслеживания (выравнивание по точкам другой геометрии)", + "moveInferenceExtension": "Продолжения линий и граней стен", + "moveInferenceParallel": "Параллельно / перпендикулярно соседним линиям", + "moveInferenceHint": "Задержите курсор на точке, чтобы захватить её, удерживайте Shift, чтобы зафиксировать направляющую, Tab переключает между расстоянием и углом. Полярные направляющие следуют настройкам полярного отслеживания.", "navigation": "Навигация", "wheelZoomWithoutCtrl": "Масштабирование колёсиком мыши (без удержания Ctrl)", "middleDoubleClickFit": "Двойной щелчок средней кнопкой мыши — вписать страницу", diff --git a/open-pdf-studio/js/i18n/locales/sk/common.json b/open-pdf-studio/js/i18n/locales/sk/common.json index 2cbd1e344..b451464b3 100644 --- a/open-pdf-studio/js/i18n/locales/sk/common.json +++ b/open-pdf-studio/js/i18n/locales/sk/common.json @@ -303,5 +303,16 @@ "readFailed": "3D model sa nepodarilo načítať.", "partial": "Niektoré časti 3D modelu sa nepodarilo načítať (vynechané: {{n}}).", "viewerFailed": "3D zobrazenie sa nepodarilo spustiť." + }, + "moveInference": { + "onAxisX": "Na osi X", + "onAxisY": "Na osi Y", + "fromPoint": "Od bodu", + "extension": "Na predĺžení", + "parallel": "Rovnobežne", + "perpendicular": "Kolmo", + "intersection": "Priesečník", + "polar": "Polárne", + "locked": "Uzamknuté" } } diff --git a/open-pdf-studio/js/i18n/locales/sk/preferences.json b/open-pdf-studio/js/i18n/locales/sk/preferences.json index b4e7ef87b..59d098b7e 100644 --- a/open-pdf-studio/js/i18n/locales/sk/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sk/preferences.json @@ -166,6 +166,11 @@ "deletion": "Odstraňovanie", "confirmBeforeDeleting": "Potvrdiť pred odstránením anotácií", "snapToPdfContent": "Prichytiť k obsahu výkresu PDF", + "moveInference": "Pomocné čiary pri presune (G / mv)", + "moveInferenceTracking": "Sledovacie čiary (zarovnanie k bodom inej geometrie)", + "moveInferenceExtension": "Predĺženia čiar a líc stien", + "moveInferenceParallel": "Rovnobežne / kolmo k blízkym čiaram", + "moveInferenceHint": "Krátko podržte kurzor nad bodom, aby sa zachytil, podržte Shift na uzamknutie pomocnej čiary, Tab prepína medzi vzdialenosťou a uhlom. Polárne pomocné čiary sa riadia nastavením polárneho sledovania.", "navigation": "Navigácia", "wheelZoomWithoutCtrl": "Lupa kolieskom myši (bez držania Ctrl)", "middleDoubleClickFit": "Dvojklik stredným tlačidlom myši prispôsobí stránku", diff --git a/open-pdf-studio/js/i18n/locales/sr/common.json b/open-pdf-studio/js/i18n/locales/sr/common.json index c809567e3..e9914779b 100644 --- a/open-pdf-studio/js/i18n/locales/sr/common.json +++ b/open-pdf-studio/js/i18n/locales/sr/common.json @@ -303,5 +303,16 @@ "readFailed": "3D модел није могуће прочитати.", "partial": "Неке делове 3D модела није могуће прочитати (прескочено: {{n}}).", "viewerFailed": "3D приказ није могуће покренути." + }, + "moveInference": { + "onAxisX": "На X оси", + "onAxisY": "На Y оси", + "fromPoint": "Од тачке", + "extension": "На продужетку", + "parallel": "Паралелно", + "perpendicular": "Управно", + "intersection": "Пресек", + "polar": "Поларно", + "locked": "Закључано" } } diff --git a/open-pdf-studio/js/i18n/locales/sr/preferences.json b/open-pdf-studio/js/i18n/locales/sr/preferences.json index ef796657d..e66488e37 100644 --- a/open-pdf-studio/js/i18n/locales/sr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sr/preferences.json @@ -166,6 +166,11 @@ "deletion": "Брисање", "confirmBeforeDeleting": "Потврди пре брисања анотација", "snapToPdfContent": "Привлачење на садржај PDF цртежа", + "moveInference": "Помоћне линије при померању (G / mv)", + "moveInferenceTracking": "Линије праћења (поравнање са тачкама друге геометрије)", + "moveInferenceExtension": "Продужеци линија и лица зидова", + "moveInferenceParallel": "Паралелно / управно на оближње линије", + "moveInferenceHint": "Задржите показивач кратко изнад тачке да је ухватите, држите Shift да закључате помоћну линију, Tab прелази између растојања и угла. Поларне помоћне линије прате подешавања поларног праћења.", "navigation": "Навигација", "wheelZoomWithoutCtrl": "Зумирање точкићем миша (без држања тастера Ctrl)", "middleDoubleClickFit": "Двоструки клик средњим тастером миша уклапа страницу", diff --git a/open-pdf-studio/js/i18n/locales/sv/common.json b/open-pdf-studio/js/i18n/locales/sv/common.json index bf7747bec..923ca1509 100644 --- a/open-pdf-studio/js/i18n/locales/sv/common.json +++ b/open-pdf-studio/js/i18n/locales/sv/common.json @@ -303,5 +303,16 @@ "readFailed": "3D-modellen kunde inte läsas.", "partial": "Vissa delar av 3D-modellen kunde inte läsas ({{n}} hoppades över).", "viewerFailed": "3D-vyn kunde inte startas." + }, + "moveInference": { + "onAxisX": "På X-axeln", + "onAxisY": "På Y-axeln", + "fromPoint": "Från punkt", + "extension": "På förlängning", + "parallel": "Parallell", + "perpendicular": "Vinkelrät", + "intersection": "Skärningspunkt", + "polar": "Polär", + "locked": "Låst" } } diff --git a/open-pdf-studio/js/i18n/locales/sv/preferences.json b/open-pdf-studio/js/i18n/locales/sv/preferences.json index 3e1858350..9558490d5 100644 --- a/open-pdf-studio/js/i18n/locales/sv/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sv/preferences.json @@ -166,6 +166,11 @@ "deletion": "Borttagning", "confirmBeforeDeleting": "Bekräfta innan anteckningar raderas", "snapToPdfContent": "Fäst mot PDF-ritningsinnehåll", + "moveInference": "Hjälplinjer vid flytt (G / mv)", + "moveInferenceTracking": "Spårningslinjer (justera mot punkter i annan geometri)", + "moveInferenceExtension": "Förlängningar av linjer och väggytor", + "moveInferenceParallel": "Parallell / vinkelrät mot närliggande linjer", + "moveInferenceHint": "Håll pekaren kort över en punkt för att fånga den, håll ned Shift för att låsa en hjälplinje, Tab växlar mellan avstånd och vinkel. Polära hjälplinjer följer inställningarna för polärspårning.", "navigation": "Navigering", "wheelZoomWithoutCtrl": "Zooma med mushjulet (utan att hålla ned Ctrl)", "middleDoubleClickFit": "Dubbelklicka med mittenknappen för att anpassa sidan", diff --git a/open-pdf-studio/js/i18n/locales/sw/common.json b/open-pdf-studio/js/i18n/locales/sw/common.json index 5446963f4..3e45adfaa 100644 --- a/open-pdf-studio/js/i18n/locales/sw/common.json +++ b/open-pdf-studio/js/i18n/locales/sw/common.json @@ -303,5 +303,16 @@ "readFailed": "Modeli ya 3D haikuweza kusomwa.", "partial": "Baadhi ya sehemu za modeli ya 3D hazikuweza kusomwa ({{n}} zimerukwa).", "viewerFailed": "Mwonekano wa 3D haukuweza kuanzishwa." + }, + "moveInference": { + "onAxisX": "Kwenye mhimili X", + "onAxisY": "Kwenye mhimili Y", + "fromPoint": "Kutoka pointi", + "extension": "Kwenye mwendelezo", + "parallel": "Sambamba", + "perpendicular": "Pembemraba", + "intersection": "Makutano", + "polar": "Kipolari", + "locked": "Imefungwa" } } diff --git a/open-pdf-studio/js/i18n/locales/sw/preferences.json b/open-pdf-studio/js/i18n/locales/sw/preferences.json index 263c8c2a1..8e52bf97d 100644 --- a/open-pdf-studio/js/i18n/locales/sw/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sw/preferences.json @@ -166,6 +166,11 @@ "deletion": "Ufutaji", "confirmBeforeDeleting": "Thibitisha kabla ya kufuta maoni", "snapToPdfContent": "Pachika kwenye Maudhui ya Mchoro wa PDF", + "moveInference": "Mistari ya makisio wakati wa kusogeza (G / mv)", + "moveInferenceTracking": "Mistari ya ufuatiliaji (panga na pointi za jiometri nyingine)", + "moveInferenceExtension": "Miendelezo ya mistari na nyuso za kuta", + "moveInferenceParallel": "Sambamba / pembemraba na mistari iliyo karibu", + "moveInferenceHint": "Weka kiteuzi kwa muda mfupi juu ya pointi ili kuinasa, shikilia Shift ili kufunga mstari mwongozo, Tab hubadilisha kati ya umbali na pembe. Miongozo ya kipolari hufuata mipangilio ya ufuatiliaji wa kipolari.", "navigation": "Urambazaji", "wheelZoomWithoutCtrl": "Kuza kwa gurudumu la kipanya (bila kushikilia Ctrl)", "middleDoubleClickFit": "Bofya mara mbili kitufe cha kati cha kipanya ili kutoshea ukurasa", diff --git a/open-pdf-studio/js/i18n/locales/ta/common.json b/open-pdf-studio/js/i18n/locales/ta/common.json index 6e6abd372..3f29d326f 100644 --- a/open-pdf-studio/js/i18n/locales/ta/common.json +++ b/open-pdf-studio/js/i18n/locales/ta/common.json @@ -303,5 +303,16 @@ "readFailed": "3D மாதிரியைப் படிக்க முடியவில்லை.", "partial": "3D மாதிரியின் சில பகுதிகளைப் படிக்க முடியவில்லை ({{n}} தவிர்க்கப்பட்டன).", "viewerFailed": "3D காட்சியைத் தொடங்க முடியவில்லை." + }, + "moveInference": { + "onAxisX": "X அச்சில்", + "onAxisY": "Y அச்சில்", + "fromPoint": "புள்ளியிலிருந்து", + "extension": "நீட்சியில்", + "parallel": "இணை", + "perpendicular": "செங்குத்து", + "intersection": "வெட்டுப்புள்ளி", + "polar": "துருவம்", + "locked": "பூட்டப்பட்டது" } } diff --git a/open-pdf-studio/js/i18n/locales/ta/preferences.json b/open-pdf-studio/js/i18n/locales/ta/preferences.json index 1e82f77d8..5277b98fc 100644 --- a/open-pdf-studio/js/i18n/locales/ta/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ta/preferences.json @@ -166,6 +166,11 @@ "deletion": "நீக்கம்", "confirmBeforeDeleting": "குறிப்புரைகளை நீக்கும் முன் உறுதிப்படுத்து", "snapToPdfContent": "PDF வரைபட உள்ளடக்கத்திற்குப் பொருத்து", + "moveInference": "நகர்த்தும்போது வழிகாட்டிக் கோடுகள் (G / mv)", + "moveInferenceTracking": "கண்காணிப்புக் கோடுகள் (பிற வடிவியல் புள்ளிகளுடன் சீரமைத்தல்)", + "moveInferenceExtension": "கோடுகள் மற்றும் சுவர் முகங்களின் நீட்சிகள்", + "moveInferenceParallel": "அருகிலுள்ள கோடுகளுக்கு இணை / செங்குத்து", + "moveInferenceHint": "ஒரு புள்ளியைப் பிடிக்க அதன் மேல் சுட்டியைச் சிறிது நேரம் வைக்கவும், வழிகாட்டியைப் பூட்ட Shift ஐ அழுத்திப் பிடிக்கவும், Tab தூரத்துக்கும் கோணத்துக்கும் இடையே மாற்றும். துருவ வழிகாட்டிகள் துருவக் கண்காணிப்பு அமைப்புகளைப் பின்பற்றும்.", "navigation": "வழிசெலுத்தல்", "wheelZoomWithoutCtrl": "சுட்டி உருளையால் பெரிதாக்கு (Ctrl அழுத்தாமல்)", "middleDoubleClickFit": "பக்கத்தைப் பொருத்த சுட்டியின் நடு பொத்தானை இருமுறை சொடுக்கவும்", diff --git a/open-pdf-studio/js/i18n/locales/th/common.json b/open-pdf-studio/js/i18n/locales/th/common.json index fda3381ed..d1f222a29 100644 --- a/open-pdf-studio/js/i18n/locales/th/common.json +++ b/open-pdf-studio/js/i18n/locales/th/common.json @@ -303,5 +303,16 @@ "readFailed": "ไม่สามารถอ่านโมเดล 3D ได้", "partial": "ไม่สามารถอ่านบางส่วนของโมเดล 3D ได้ (ข้าม {{n}})", "viewerFailed": "ไม่สามารถเริ่มมุมมอง 3D ได้" + }, + "moveInference": { + "onAxisX": "บนแกน X", + "onAxisY": "บนแกน Y", + "fromPoint": "จากจุด", + "extension": "บนส่วนต่อขยาย", + "parallel": "ขนาน", + "perpendicular": "ตั้งฉาก", + "intersection": "จุดตัด", + "polar": "เชิงขั้ว", + "locked": "ล็อกแล้ว" } } diff --git a/open-pdf-studio/js/i18n/locales/th/preferences.json b/open-pdf-studio/js/i18n/locales/th/preferences.json index 5813fb2b5..e1786ab54 100644 --- a/open-pdf-studio/js/i18n/locales/th/preferences.json +++ b/open-pdf-studio/js/i18n/locales/th/preferences.json @@ -166,6 +166,11 @@ "deletion": "การลบ", "confirmBeforeDeleting": "ยืนยันก่อนลบคำอธิบายประกอบ", "snapToPdfContent": "สแนปไปยังเนื้อหาแบบใน PDF", + "moveInference": "เส้นช่วยอนุมานขณะย้าย (G / mv)", + "moveInferenceTracking": "เส้นติดตาม (จัดแนวกับจุดของรูปทรงอื่น)", + "moveInferenceExtension": "ส่วนต่อขยายของเส้นและผิวผนัง", + "moveInferenceParallel": "ขนาน / ตั้งฉากกับเส้นที่อยู่ใกล้", + "moveInferenceHint": "วางเคอร์เซอร์บนจุดสักครู่เพื่อจับจุดนั้น กด Shift ค้างไว้เพื่อล็อกเส้นช่วย และ Tab สลับระหว่างระยะทางกับมุม เส้นช่วยเชิงขั้วเป็นไปตามการตั้งค่าการติดตามเชิงขั้ว", "navigation": "การนำทาง", "wheelZoomWithoutCtrl": "ซูมด้วยล้อเมาส์ (โดยไม่ต้องกด Ctrl)", "middleDoubleClickFit": "ดับเบิลคลิกปุ่มกลางของเมาส์เพื่อปรับให้พอดีหน้า", diff --git a/open-pdf-studio/js/i18n/locales/tr/common.json b/open-pdf-studio/js/i18n/locales/tr/common.json index 2c518d38e..2a82e0fcd 100644 --- a/open-pdf-studio/js/i18n/locales/tr/common.json +++ b/open-pdf-studio/js/i18n/locales/tr/common.json @@ -303,5 +303,16 @@ "readFailed": "3D model okunamadı.", "partial": "3D modelin bazı bölümleri okunamadı ({{n}} atlandı).", "viewerFailed": "3D görünüm başlatılamadı." + }, + "moveInference": { + "onAxisX": "X ekseninde", + "onAxisY": "Y ekseninde", + "fromPoint": "Noktadan", + "extension": "Uzantıda", + "parallel": "Paralel", + "perpendicular": "Dik", + "intersection": "Kesişim", + "polar": "Kutupsal", + "locked": "Kilitli" } } diff --git a/open-pdf-studio/js/i18n/locales/tr/preferences.json b/open-pdf-studio/js/i18n/locales/tr/preferences.json index 9f0a8c5c4..c8602eaa2 100644 --- a/open-pdf-studio/js/i18n/locales/tr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/tr/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "En Yakına Yakala", "objectSnapRadius": "Yakalama Yarıçapı (piksel)", "snapToPdfContent": "PDF Çizim İçeriğine Yakala", + "moveInference": "Taşırken çıkarım (G / mv)", + "moveInferenceTracking": "İzleme çizgileri (diğer geometrinin noktalarıyla hizala)", + "moveInferenceExtension": "Çizgilerin ve duvar yüzlerinin uzantıları", + "moveInferenceParallel": "Yakındaki çizgilere paralel / dik", + "moveInferenceHint": "Bir noktayı yakalamak için imleci kısa süre üzerinde tutun, bir kılavuzu kilitlemek için Shift tuşunu basılı tutun, Tab mesafe ile açı arasında geçiş yapar. Kutupsal kılavuzlar kutupsal izleme ayarlarını izler.", "creation": "Oluşturma", "autoSelectAfterCreation": "Oluşturduktan sonra açıklamayı otomatik seç", "deletion": "Silme", diff --git a/open-pdf-studio/js/i18n/locales/uk/common.json b/open-pdf-studio/js/i18n/locales/uk/common.json index 177318efc..163a58fa6 100644 --- a/open-pdf-studio/js/i18n/locales/uk/common.json +++ b/open-pdf-studio/js/i18n/locales/uk/common.json @@ -303,5 +303,16 @@ "readFailed": "Не вдалося прочитати 3D-модель.", "partial": "Не вдалося прочитати деякі частини 3D-моделі (пропущено: {{n}}).", "viewerFailed": "Не вдалося запустити 3D-вигляд." + }, + "moveInference": { + "onAxisX": "По осі X", + "onAxisY": "По осі Y", + "fromPoint": "Від точки", + "extension": "На продовженні", + "parallel": "Паралельно", + "perpendicular": "Перпендикулярно", + "intersection": "Перетин", + "polar": "Полярно", + "locked": "Зафіксовано" } } diff --git a/open-pdf-studio/js/i18n/locales/uk/preferences.json b/open-pdf-studio/js/i18n/locales/uk/preferences.json index 48bc9ddd1..7929c4040 100644 --- a/open-pdf-studio/js/i18n/locales/uk/preferences.json +++ b/open-pdf-studio/js/i18n/locales/uk/preferences.json @@ -166,6 +166,11 @@ "deletion": "Видалення", "confirmBeforeDeleting": "Підтверджувати перед видаленням анотацій", "snapToPdfContent": "Прив'язка до вмісту креслення PDF", + "moveInference": "Підказки під час переміщення (G / mv)", + "moveInferenceTracking": "Лінії відстеження (вирівнювання за точками іншої геометрії)", + "moveInferenceExtension": "Продовження ліній і граней стін", + "moveInferenceParallel": "Паралельно / перпендикулярно до сусідніх ліній", + "moveInferenceHint": "Затримайте курсор на точці, щоб захопити її, утримуйте Shift, щоб зафіксувати напрямну, Tab перемикає між відстанню та кутом. Полярні напрямні відповідають налаштуванням полярного відстеження.", "navigation": "Навігація", "wheelZoomWithoutCtrl": "Масштабування коліщатком миші (без утримання Ctrl)", "middleDoubleClickFit": "Подвійне клацання середньою кнопкою миші — вписати сторінку", diff --git a/open-pdf-studio/js/i18n/locales/ur/common.json b/open-pdf-studio/js/i18n/locales/ur/common.json index db1b044d4..c48d180b6 100644 --- a/open-pdf-studio/js/i18n/locales/ur/common.json +++ b/open-pdf-studio/js/i18n/locales/ur/common.json @@ -303,5 +303,16 @@ "readFailed": "3D ماڈل پڑھا نہیں جا سکا۔", "partial": "3D ماڈل کے کچھ حصے پڑھے نہیں جا سکے ({{n}} چھوڑ دیے گئے)۔", "viewerFailed": "3D منظر شروع نہیں کیا جا سکا۔" + }, + "moveInference": { + "onAxisX": "X محور پر", + "onAxisY": "Y محور پر", + "fromPoint": "نقطے سے", + "extension": "توسیع پر", + "parallel": "متوازی", + "perpendicular": "عمودی", + "intersection": "تقاطع", + "polar": "پولر", + "locked": "مقفل" } } diff --git a/open-pdf-studio/js/i18n/locales/ur/preferences.json b/open-pdf-studio/js/i18n/locales/ur/preferences.json index e196fe658..c009e36b5 100644 --- a/open-pdf-studio/js/i18n/locales/ur/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ur/preferences.json @@ -166,6 +166,11 @@ "deletion": "حذف", "confirmBeforeDeleting": "تشریحات حذف کرنے سے پہلے تصدیق کریں", "snapToPdfContent": "PDF ڈرائنگ مواد سے سنیپ کریں", + "moveInference": "منتقل کرتے وقت رہنما خطوط (G / mv)", + "moveInferenceTracking": "ٹریکنگ خطوط (دوسری جیومیٹری کے نقاط سے سیدھ)", + "moveInferenceExtension": "خطوط اور دیوار کی سطحوں کی توسیع", + "moveInferenceParallel": "قریبی خطوط کے متوازی / عمودی", + "moveInferenceHint": "کسی نقطے کو پکڑنے کے لیے کرسر کو لمحہ بھر اس پر رکھیں، رہنما خط کو مقفل کرنے کے لیے Shift دبائے رکھیں، Tab فاصلے اور زاویے کے درمیان بدلتا ہے۔ پولر رہنما خطوط پولر ٹریکنگ کی ترتیبات کی پیروی کرتے ہیں۔", "navigation": "نیویگیشن", "wheelZoomWithoutCtrl": "ماؤس وہیل سے زوم کریں (Ctrl دبائے بغیر)", "middleDoubleClickFit": "صفحہ فٹ کرنے کے لیے ماؤس کے درمیانی بٹن پر ڈبل کلک کریں", diff --git a/open-pdf-studio/js/i18n/locales/vi/common.json b/open-pdf-studio/js/i18n/locales/vi/common.json index 1fb786dab..a0f05062f 100644 --- a/open-pdf-studio/js/i18n/locales/vi/common.json +++ b/open-pdf-studio/js/i18n/locales/vi/common.json @@ -303,5 +303,16 @@ "readFailed": "Không thể đọc mô hình 3D.", "partial": "Không thể đọc một số phần của mô hình 3D (đã bỏ qua {{n}}).", "viewerFailed": "Không thể khởi động chế độ xem 3D." + }, + "moveInference": { + "onAxisX": "Trên trục X", + "onAxisY": "Trên trục Y", + "fromPoint": "Từ điểm", + "extension": "Trên đường kéo dài", + "parallel": "Song song", + "perpendicular": "Vuông góc", + "intersection": "Giao điểm", + "polar": "Cực", + "locked": "Đã khóa" } } diff --git a/open-pdf-studio/js/i18n/locales/vi/preferences.json b/open-pdf-studio/js/i18n/locales/vi/preferences.json index 13a65f7ad..0c813379a 100644 --- a/open-pdf-studio/js/i18n/locales/vi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/vi/preferences.json @@ -166,6 +166,11 @@ "deletion": "Xóa", "confirmBeforeDeleting": "Xác nhận trước khi xóa chú thích", "snapToPdfContent": "Bắt dính nội dung bản vẽ PDF", + "moveInference": "Đường suy luận khi di chuyển (G / mv)", + "moveInferenceTracking": "Đường theo dõi (căn theo các điểm của hình khác)", + "moveInferenceExtension": "Đường kéo dài của đường thẳng và mặt tường", + "moveInferenceParallel": "Song song / vuông góc với các đường gần đó", + "moveInferenceHint": "Giữ con trỏ trên một điểm trong giây lát để thu điểm đó, giữ Shift để khóa một đường dẫn, Tab chuyển giữa khoảng cách và góc. Các đường dẫn cực tuân theo cài đặt theo dõi cực.", "navigation": "Điều hướng", "wheelZoomWithoutCtrl": "Thu phóng bằng con lăn chuột (không cần giữ Ctrl)", "middleDoubleClickFit": "Nhấp đúp nút giữa chuột để vừa trang", diff --git a/open-pdf-studio/js/i18n/locales/zh/common.json b/open-pdf-studio/js/i18n/locales/zh/common.json index a2b0ccf08..af8c2364f 100644 --- a/open-pdf-studio/js/i18n/locales/zh/common.json +++ b/open-pdf-studio/js/i18n/locales/zh/common.json @@ -303,5 +303,16 @@ "readFailed": "无法读取 3D 模型。", "partial": "3D 模型的部分内容无法读取(已跳过 {{n}} 个)。", "viewerFailed": "无法启动 3D 视图。" + }, + "moveInference": { + "onAxisX": "在 X 轴上", + "onAxisY": "在 Y 轴上", + "fromPoint": "自点", + "extension": "在延长线上", + "parallel": "平行", + "perpendicular": "垂直", + "intersection": "交点", + "polar": "极轴", + "locked": "已锁定" } } diff --git a/open-pdf-studio/js/i18n/locales/zh/preferences.json b/open-pdf-studio/js/i18n/locales/zh/preferences.json index 76a3dcf21..d16f7716f 100644 --- a/open-pdf-studio/js/i18n/locales/zh/preferences.json +++ b/open-pdf-studio/js/i18n/locales/zh/preferences.json @@ -162,6 +162,11 @@ "snapToNearest": "捕捉到最近点", "objectSnapRadius": "捕捉半径(像素)", "snapToPdfContent": "捕捉到 PDF 图形内容", + "moveInference": "移动时的推断辅助线 (G / mv)", + "moveInferenceTracking": "追踪线(与其他图形的点对齐)", + "moveInferenceExtension": "直线和墙面的延长线", + "moveInferenceParallel": "与附近直线平行 / 垂直", + "moveInferenceHint": "将光标在某点上短暂停留即可获取该点,按住 Shift 锁定辅助线,Tab 在距离和角度之间切换。极轴辅助线遵循极轴追踪设置。", "creation": "创建", "autoSelectAfterCreation": "创建后自动选择批注", "deletion": "删除", diff --git a/open-pdf-studio/js/solid/components/TypeLengthHUD.jsx b/open-pdf-studio/js/solid/components/TypeLengthHUD.jsx index b9cb4a96f..e4cc62a57 100644 --- a/open-pdf-studio/js/solid/components/TypeLengthHUD.jsx +++ b/open-pdf-studio/js/solid/components/TypeLengthHUD.jsx @@ -4,6 +4,7 @@ import { typeLengthCursor, typeLengthFormat, typeLengthActive, + typeLengthFocus, getTypeLengthStart, } from '../../tools/type-length-input.js'; import { state, getActiveDocument } from '../../core/state.js'; @@ -100,7 +101,21 @@ export default function TypeLengthHUD() { when={typeLengthBuffer().length > 0} fallback={{_placeholder()}} > - {typeLengthBuffer()} + {typeLengthBuffer()}} + > + {/* Polar entry: the field Tab focused is underlined. */} + + + {typeLengthBuffer().slice(0, typeLengthBuffer().indexOf('<'))} + + < + + {typeLengthBuffer().slice(typeLengthBuffer().indexOf('<') + 1)} + + + {_hintFor(typeLengthFormat()).text} diff --git a/open-pdf-studio/js/solid/components/preferences/BehaviorTab.jsx b/open-pdf-studio/js/solid/components/preferences/BehaviorTab.jsx index 9c3102ea1..2e396db5f 100644 --- a/open-pdf-studio/js/solid/components/preferences/BehaviorTab.jsx +++ b/open-pdf-studio/js/solid/components/preferences/BehaviorTab.jsx @@ -139,6 +139,31 @@ export default function BehaviorTab(props) {
+
+ {t('behavior.moveInference')} +
+ +
+
+ +
+
+ +
+
+ {t('behavior.moveInferenceHint')} +
+
+
{t('behavior.creation')}
diff --git a/open-pdf-studio/js/tools/g-move-mode.js b/open-pdf-studio/js/tools/g-move-mode.js index cf6a3c172..7abe33d1f 100644 --- a/open-pdf-studio/js/tools/g-move-mode.js +++ b/open-pdf-studio/js/tools/g-move-mode.js @@ -19,6 +19,10 @@ // - 'Y' constrains movement to the Y-axis (press the same key again to clear) // - Mouse click (left) or 'Enter' commits the new position (records undo) // - 'Escape' or right-click cancels and restores original positions +// - Inference (#519, move-inference.js): tracking/alignment lines, acquired +// points (hover briefly), extension lines, parallel/perpendicular and +// polar guides; Shift locks the shown guide, Tab switches the typed +// entry between distance and angle // // Implementation notes: // - originals[] holds deep clones (cloneAnnotation) of each selected annotation @@ -48,9 +52,15 @@ import { getCachedPdfSnapPoints } from './pdf-snap-extractor.js'; import { enterTypeLengthMode, exitTypeLengthMode, typeLengthHasBuffer, consumeKey as typeLengthConsumeKey, applyToEndpoint as typeLengthApply, - setTypeLengthCursorScreen, + setTypeLengthCursorScreen, cycleTypeLengthField, } from './type-length-input.js'; import { snapAngle } from '../utils/helpers.js'; +import { + createPointIndex, createMoveInferenceSession, buildInferenceOverlay, publishInferenceOverlay, + axisLockLine, isPointSnapType, snapPriority, resolveSnapPriority, INFERENCE_PRIORITY, +} from './move-inference.js'; +import { weergaveRotatie } from '../pdf/weergave-ruimte.js'; +import { isKwartslag } from '../pdf/weergave-rotatie.js'; // Ids of every annotation in the move session — the moving selection must // never snap onto itself (single OR multi). @@ -67,10 +77,20 @@ function _excludeSet() { function _snapSessionPoint(c, { includeTargets = false } = {}) { const doc = getActiveDocument(); if (!doc) return { x: c.x, y: c.y, snapped: false }; - return performSnap( - c.x, c.y, doc.annotations || [], doc.currentPage || 1, doc.scale || 1.5, + const page = _sessionPage(); + const r = performSnap( + c.x, c.y, doc.annotations || [], page, doc.scale || 1.5, includeTargets ? undefined : _excludeSet() ); + // Page tag: the continuous render path draws the indicator on this page only. + return r.snapped ? { ...r, page } : r; +} + +// The page the session works on: the page of the moved annotations (in +// continuous mode doc.currentPage need not be the page under the cursor). +function _sessionPage() { + const doc = getActiveDocument(); + return mode?.targets?.[0]?.page || doc?.currentPage || 1; } // Corner-to-corner object snap: when a snap point OF THE MOVING SELECTION @@ -78,38 +98,72 @@ function _snapSessionPoint(c, { includeTargets = false } = {}) { // point, the delta is adjusted so the two points click together exactly — // "de hoek van het element" snaps, not just the cursor. Candidates are // cached at session start; own points simply translate with the delta. -const _OBJ_SNAP_MAX_STATIC = 6000; // guard against pathological documents +// Static candidates live in a spatial index (inference-engine.js), so dense +// CAD pages keep object snap and inference instead of switching them off. +// PDF content beyond this many points is limited to a window around the +// cursor at session start. +const _OBJ_SNAP_MAX_STATIC = 250000; +const _PDF_WINDOW = 3000; // app units around the session start function _cacheObjectSnapPoints() { const doc = getActiveDocument(); if (!doc || !mode) return; - const page = doc.currentPage || 1; + const page = _sessionPage(); try { mode.ownPoints = collectSnapPoints(mode.targets, page); const statics = collectSnapPoints(doc.annotations || [], page, _excludeSet()); if (state.preferences.snapToPdfContent) { - for (const pt of getCachedPdfSnapPoints(page)) statics.push(pt); + const pdf = getCachedPdfSnapPoints(page); + const room = _OBJ_SNAP_MAX_STATIC - statics.length; + if (pdf.length <= room) { + for (const pt of pdf) statics.push(pt); + } else { + const cx = mode.startSeeded ? mode.startX : (state._lastMouseAppX || 0); + const cy = mode.startSeeded ? mode.startY : (state._lastMouseAppY || 0); + for (const pt of pdf) { + if (statics.length >= _OBJ_SNAP_MAX_STATIC) break; + if (Math.abs(pt.x - cx) <= _PDF_WINDOW && Math.abs(pt.y - cy) <= _PDF_WINDOW) statics.push(pt); + } + } } const c2d = doc.cursor2D; if (c2d && c2d.page === page) statics.push({ x: c2d.x, y: c2d.y, type: 'endpoint', annotation: null }); - mode.staticPoints = statics.length <= _OBJ_SNAP_MAX_STATIC ? statics : []; + mode.staticPoints = statics; } catch (_) { mode.ownPoints = []; mode.staticPoints = []; } + mode.staticIndex = createPointIndex(mode.staticPoints); + try { + mode.inference = createMoveInferenceSession({ + staticIndex: mode.staticIndex, + annotations: doc.annotations || [], + page, + excludeIds: _excludeSet(), + ownPoints: mode.ownPoints, + start: { x: mode.startX, y: mode.startY }, + pdfSegments: !!(state.preferences.snapToPdfContent && state.preferences.enableObjectSnap), + onAcquire: () => { + if (!mode || mode.awaitingBase || !mode.rawCursor) return; + _track(mode.rawCursor, mode.lastShift); + redraw(); + }, + }); + } catch (_) { + mode.inference = null; + } } // Find the best own-point→static-point pairing for a candidate delta. // Returns { dist, ddx, ddy, sp } or null. function _bestObjectSnap(dx, dy, tol) { - if (!mode?.ownPoints?.length || !mode?.staticPoints?.length) return null; + if (!mode?.ownPoints?.length || !mode?.staticIndex?.size) return null; let best = null; for (const op of mode.ownPoints) { const px = op.x + dx, py = op.y + dy; - for (const sp of mode.staticPoints) { - const ddx = sp.x - px, ddy = sp.y - py; - const d = Math.hypot(ddx, ddy); - if (d <= tol && (!best || d < best.dist)) best = { dist: d, ddx, ddy, sp }; + const n = mode.staticIndex.nearest(px, py, tol); + if (n && (!best || n.dist < best.dist)) { + best = { dist: n.dist, ddx: n.point.x - px, ddy: n.point.y - py, sp: n.point }; } } return best; @@ -220,79 +274,141 @@ function onMouseMove(e) { enterTypeLengthMode(mode.startX, mode.startY); return; } - // Shift = CAD ortho/angle constraint: snap the start→cursor direction to - // preferences.angleSnapDegrees multiples (45°). The constrained cursor is - // the basis for everything below (typed distance follows the snapped ray). + _track(cRaw, e.shiftKey); + redraw(); +} + +// One tracking step: cursor → (snapped / inferred / typed) delta, applied to +// the selection. Also run by the acquisition timer and on Shift release. +function _track(cRaw, shiftKey) { + const prefs = state.preferences; + const doc = getActiveDocument(); + const scale = doc?.scale || 1.5; + const start = { x: mode.startX, y: mode.startY }; + const page = _sessionPage(); + mode.rawCursor = cRaw; + mode.lastShift = !!shiftKey; + if (mode.inference) mode.inference.setStart(start); + + // Shift: with a guide on screen it LOCKS that guide (the cursor may move + // away, the move stays on the line); without one it is the CAD ortho/angle + // constraint: the start→cursor direction snaps to angleSnapDegrees + // multiples (45°). + if (!shiftKey) mode.lockedLine = null; + else if (!mode.lockedLine && mode.lastInference?.lockable) mode.lockedLine = mode.lastInference.lockable; + const constrainAngle = !!shiftKey && !mode.lockedLine && prefs.enableAngleSnap; let cur = cRaw; - if (e.shiftKey && state.preferences.enableAngleSnap) { + if (constrainAngle) { const ddx = cRaw.x - mode.startX; const ddy = cRaw.y - mode.startY; const len = Math.hypot(ddx, ddy); if (len > 1e-6) { const ang = snapAngle(Math.atan2(ddy, ddx) * (180 / Math.PI), - state.preferences.angleSnapDegrees || 45) * (Math.PI / 180); + prefs.angleSnapDegrees || 45) * (Math.PI / 180); cur = { x: mode.startX + len * Math.cos(ang), y: mode.startY + len * Math.sin(ang) }; } } - mode.lastCursor = cur; - // Typed coordinate input ("100", "100,50", "100<45", "=x,y") overrides - // every snap: the number IS the instruction. - const typed = typeLengthApply(mode.startX, mode.startY, cur.x, cur.y); - if (typed.constrained) { - let dx = typed.x - mode.startX; - let dy = typed.y - mode.startY; - if (mode.lockAxis === 'x') dy = 0; - else if (mode.lockAxis === 'y') dx = 0; - state.lastSnapResult = null; - mode.lastDx = dx; - mode.lastDy = dy; - applyDeltaToAll(dx, dy); - redraw(); - return; - } - - // Two independent snap candidates against the (possibly angle-constrained) - // cursor; the closest one wins (stacking them would double-correct): - // 1. cursor snap — the cursor itself lands on a static point (also the - // precision channel of the 'mv' base-point flow) - // 2. object snap — a corner/endpoint of the MOVING selection lands on a - // static point ("hoek van het element" klikt vast) - let dx = cur.x - mode.startX; - let dy = cur.y - mode.startY; + // Candidates, resolved by priority (inference-engine.js): + // 1. point object snaps — the cursor itself, or a corner/endpoint of the + // MOVING selection ("hoek van het element"), lands on a static point; + // of these two the closest wins (stacking them would double-correct) + // 2. inference — intersections of guides, tracking / extension / + // parallel / perpendicular / polar / axis lines + // 3. soft snaps — edge, nearest, grid + // Typed coordinates override all of them (below). + let dx = cur.x - start.x; + let dy = cur.y - start.y; if (mode.lockAxis === 'x') dy = 0; else if (mode.lockAxis === 'y') dx = 0; - let indicator = null; - // While Shift constrains the angle, object/cursor snaps are off — the - // constraint is the instruction. - const constrainAngle = e.shiftKey && state.preferences.enableAngleSnap; - const cursorSnap = constrainAngle ? { snapped: false } : _snapSessionPoint(cur); + const freeSnaps = !constrainAngle && !mode.lockedLine; + const cursorSnap = freeSnaps ? _snapSessionPoint(cur) : { snapped: false }; const cursorDist = cursorSnap.snapped ? Math.hypot(cursorSnap.x - cur.x, cursorSnap.y - cur.y) : Infinity; - const doc = getActiveDocument(); - const tol = (state.preferences.objectSnapRadius || 12) / (doc?.scale || 1.5); + const tol = (prefs.objectSnapRadius || 12) / scale; // Object snap: skipped while an axis is locked (it would break the lock) // and in the base-point flow (the user picked their reference point). - const objSnap = (!mode.lockAxis && !mode.basePointFlow && !constrainAngle) + const objSnap = (!mode.lockAxis && !mode.basePointFlow && freeSnaps) ? _bestObjectSnap(dx, dy, tol) : null; + const cands = []; if (objSnap && objSnap.dist <= cursorDist) { + cands.push({ src: 'object', priority: INFERENCE_PRIORITY.point, dist: objSnap.dist, hover: objSnap.sp }); + } else if (cursorSnap.snapped) { + cands.push({ src: 'cursor', priority: snapPriority(cursorSnap.type), dist: cursorDist, + hover: isPointSnapType(cursorSnap.type) ? cursorSnap : null }); + } + const pointWins = cands.length && cands[0].priority >= INFERENCE_PRIORITY.point; + // Hovering a static point briefly acquires it as a tracking point. + if (mode.inference) mode.inference.hover(pointWins ? cands[0].hover : null); + + let inferred = null; + if (!pointWins && !constrainAngle && mode.inference) { + const lockLine = mode.lockedLine || axisLockLine(start, mode.lockAxis); + try { + inferred = mode.inference.infer(cur, { prefs, scale, lockLine }); + } catch (err) { + console.warn('[gmove] inference failed:', err); + inferred = null; + } + // An X/Y axis lock alone is not an inference: only crossings with guides. + if (inferred && inferred.kind === 'locked' && !mode.lockedLine) inferred = null; + if (inferred) cands.push({ src: 'inference', priority: inferred.priority, dist: inferred.dist }); + } + + const win = resolveSnapPriority(cands); + let indicator = null; + if (win?.src === 'object') { dx += objSnap.ddx; dy += objSnap.ddy; - indicator = { x: objSnap.sp.x, y: objSnap.sp.y, snapped: true, type: objSnap.sp.type || 'endpoint' }; - } else if (cursorSnap.snapped) { - dx = cursorSnap.x - mode.startX; - dy = cursorSnap.y - mode.startY; + indicator = { x: objSnap.sp.x, y: objSnap.sp.y, snapped: true, type: objSnap.sp.type || 'endpoint', page }; + } else if (win?.src === 'cursor') { + dx = cursorSnap.x - start.x; + dy = cursorSnap.y - start.y; if (mode.lockAxis === 'x') dy = 0; else if (mode.lockAxis === 'y') dx = 0; indicator = cursorSnap; + } else if (win?.src === 'inference') { + dx = inferred.x - start.x; + dy = inferred.y - start.y; + if (mode.lockAxis === 'x') dy = 0; + else if (mode.lockAxis === 'y') dx = 0; + } + mode.lastInference = win?.src === 'inference' ? inferred : null; + + // The (snapped / inferred) position is the DIRECTION for a typed distance. + const dir = Math.hypot(dx, dy) > 1e-9 ? { x: start.x + dx, y: start.y + dy } : cur; + mode.lastCursor = dir; + + // Typed coordinate input ("100", "100,50", "100<45", "=x,y") overrides + // every snap: the number IS the instruction. A plain distance moves along + // the inferred direction. + const typed = typeLengthApply(start.x, start.y, dir.x, dir.y); + if (typed.constrained) { + dx = typed.x - start.x; + dy = typed.y - start.y; + if (mode.lockAxis === 'x') dy = 0; + else if (mode.lockAxis === 'y') dx = 0; + indicator = null; } state.lastSnapResult = indicator; + _publishOverlay(); mode.lastDx = dx; mode.lastDy = dy; applyDeltaToAll(dx, dy); - redraw(); +} + +function _publishOverlay() { + if (!mode) return; + const doc = getActiveDocument(); + publishInferenceOverlay(buildInferenceOverlay(mode.lastInference, { + page: _sessionPage(), + start: { x: mode.startX, y: mode.startY }, + acquired: mode.inference ? mode.inference.acquired() : [], + quarterTurn: isKwartslag(weergaveRotatie(doc)), + scale: doc?.scale || 1.5, + })); } function onKeyDown(e) { @@ -307,6 +423,22 @@ function onKeyDown(e) { // with a valid buffer commits at the typed position; un-handled keys // (Escape with empty buffer, x/y, plain Enter) fall through below. if (!mode.awaitingBase && mode.startSeeded) { + // Tab switches the typed entry between distance and angle ("100" → + // "100<", then toggles the field). Always swallowed: focus must not + // leave the canvas mid-move. + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + if (cycleTypeLengthField()) { + const lc = mode.lastCursor || { x: mode.startX + 1, y: mode.startY }; + const p = typeLengthApply(mode.startX, mode.startY, lc.x, lc.y); + mode.lastDx = p.x - mode.startX; + mode.lastDy = p.y - mode.startY; + applyDeltaToAll(mode.lastDx, mode.lastDy); + redraw(); + } + return; + } const r = typeLengthConsumeKey(e.key); if (r.handled) { e.preventDefault(); @@ -404,6 +536,7 @@ function onMouseDown(e) { mode.startY = snapped.y; mode.startSeeded = true; mode.awaitingBase = false; + if (mode.inference) mode.inference.setStart({ x: mode.startX, y: mode.startY }); state.lastSnapResult = snapped.snapped ? snapped : null; // From the base point on, typed distances steer the move ("100", // "100,50", "100<45", "=x,y") — scale-region aware at the anchor. @@ -419,6 +552,16 @@ function onMouseDown(e) { } } +// Releasing Shift drops a locked guide at once (no mouse move needed). +function onKeyUp(e) { + if (!mode || e.key !== 'Shift') return; + if (mode.lockedLine && !mode.awaitingBase && mode.startSeeded && mode.rawCursor) { + mode.lockedLine = null; + _track(mode.rawCursor, false); + redraw(); + } +} + function onContextMenu(e) { // Suppress the context menu during G-mode (right-click is "cancel") if (mode) { @@ -434,6 +577,7 @@ function attachListeners() { document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('pointerdown', onMouseDown, true); document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('keyup', onKeyUp, true); document.addEventListener('contextmenu', onContextMenu, true); } @@ -443,12 +587,15 @@ function detachListeners() { document.removeEventListener('mousedown', onMouseDown, true); document.removeEventListener('pointerdown', onMouseDown, true); document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('keyup', onKeyUp, true); document.removeEventListener('contextmenu', onContextMenu, true); } function endMode() { detachListeners(); exitTypeLengthMode(); + try { mode?.inference?.dispose(); } catch (_) { /* overlay cleanup is best-effort */ } + publishInferenceOverlay(null); state.gMoveMode = null; state.lastSnapResult = null; mode = null; @@ -566,7 +713,14 @@ export function tryStartGMove(options = {}) { lastDy: 0, lastCursor: hasTracker ? { x: startX, y: startY } : null, ownPoints: [], - staticPoints: [] + staticPoints: [], + staticIndex: null, + // Inference (#519): session, last shown inference, Shift-locked guide. + inference: null, + lastInference: null, + lockedLine: null, + rawCursor: null, + lastShift: false }; // Candidates for corner-to-corner object snap; own points translate with // the delta, static geometry doesn't change during the session. diff --git a/open-pdf-studio/js/tools/inference-engine.js b/open-pdf-studio/js/tools/inference-engine.js new file mode 100644 index 000000000..8a8cbb60d --- /dev/null +++ b/open-pdf-studio/js/tools/inference-engine.js @@ -0,0 +1,573 @@ +// Inference engine for interactive moves (#519) — PURE geometry, no app state. +// +// Consumes snap candidates (points from collectSnapPoints / PDF content, +// segments from annotation edges / wall faces / PDF vector edges) and infers +// guide lines for the cursor of a move session: +// +// tracking the cursor or a snap point of the MOVING selection lines up +// horizontally / vertically with a point of other geometry +// acquired H/V tracking lines through points the user hovered briefly +// axis H/V through the base point (start of the move) +// polar polar increments around the base point (polar settings) +// extension the extension of an existing line / wall face beyond its +// endpoints +// parallel / the move direction is (nearly) parallel or perpendicular to +// perpendicular a nearby line +// +// Two guides within tolerance give an intersection snap. Every guide is a +// line in CURSOR space: a guide for a probe with offset o (probe = cursor + o) +// is the original line shifted by -o, so "probe lies on L" is the same test +// as "cursor lies on L - o". That keeps own-point and cursor guides in one +// framework and makes intersections between them trivial. +// +// Candidate collection is bounded: points live in a sorted-axis index (H/V +// alignment = binary search) plus a uniform grid (nearest-point queries), +// segments in a uniform grid with an "oversized" list. Nothing on the +// mousemove path scans the whole page. + +// ─── Priorities ──────────────────────────────────────────────────────────── +// Higher wins. Point object snaps (endpoint, corner, midpoint, centre, vertex, +// start point, quadrant, intersection of geometry …) always beat inference; +// inference beats the "soft" snaps (edge/nearest/grid). +export const INFERENCE_PRIORITY = Object.freeze({ + point: 100, + intersection: 80, + locked: 75, + acquired: 62, + tracking: 60, + extension: 60, + axis: 58, + parallel: 55, + perpendicular: 55, + polar: 50, + edge: 40, + nearest: 40, + grid: 10, +}); + +// Snap types that are NOT exact points of geometry: they lose against +// inference. Every other type (incl. types added later, e.g. 'vertex' or +// 'startpoint') counts as a point snap. +const SOFT_SNAP_TYPES = new Set(['edge', 'nearest', 'grid', 'polar']); + +export function isPointSnapType(type) { + return !!type && !SOFT_SNAP_TYPES.has(type); +} + +/** Priority of an object-snap result (performSnap / corner-to-corner). */ +export function snapPriority(type) { + if (!type) return 0; + if (isPointSnapType(type)) return INFERENCE_PRIORITY.point; + return INFERENCE_PRIORITY[type] ?? 0; +} + +/** + * Pick the winner from snap candidates: highest priority, then smallest + * distance to the cursor. Candidates: { priority, dist, ... }. + */ +export function resolveSnapPriority(candidates) { + let best = null; + for (const c of candidates) { + if (!c) continue; + if (!best || c.priority > best.priority + || (c.priority === best.priority && (c.dist ?? 0) < (best.dist ?? 0))) { + best = c; + } + } + return best; +} + +// ─── Line helpers ────────────────────────────────────────────────────────── +// A line is { px, py, dx, dy } with a unit direction. + +export function makeLine(px, py, dx, dy) { + const len = Math.hypot(dx, dy); + if (!(len > 1e-12)) return null; + return { px, py, dx: dx / len, dy: dy / len }; +} + +export function distToLine(l, x, y) { + return Math.abs((x - l.px) * l.dy - (y - l.py) * l.dx); +} + +export function projectOnLine(l, x, y) { + const t = (x - l.px) * l.dx + (y - l.py) * l.dy; + return { x: l.px + t * l.dx, y: l.py + t * l.dy, t }; +} + +/** Intersection of two lines, or null when (nearly) parallel. */ +export function intersectLines(a, b, minSin = Math.sin(2 * Math.PI / 180)) { + const cross = a.dx * b.dy - a.dy * b.dx; + if (Math.abs(cross) < minSin) return null; + const t = ((b.px - a.px) * b.dy - (b.py - a.py) * b.dx) / cross; + return { x: a.px + t * a.dx, y: a.py + t * a.dy }; +} + +function _sameLine(a, b, eps) { + const cross = Math.abs(a.dx * b.dy - a.dy * b.dx); + if (cross > 1e-3) return false; + return distToLine(a, b.px, b.py) <= eps; +} + +/** + * Screen axis of a direction: 'x' (screen horizontal), 'y' (screen vertical) + * or null. `quarterTurn` = the view is rotated by 90/270 degrees, so page + * horizontal shows vertically on screen. + */ +export function screenAxis(dx, dy, quarterTurn = false) { + const eps = 1e-6; + let ax = null; + if (Math.abs(dy) < eps && Math.abs(dx) > eps) ax = 'x'; + else if (Math.abs(dx) < eps && Math.abs(dy) > eps) ax = 'y'; + if (!ax || !quarterTurn) return ax; + return ax === 'x' ? 'y' : 'x'; +} + +// ─── Spatial indexes ─────────────────────────────────────────────────────── + +function _cellKey(cx, cy) { + // Numeric key, no string allocation on the hot path. + return (cx + 0x8000) * 0x10000 + (cy + 0x8000); +} + +/** + * Point index: sorted x / y arrays (H/V alignment by binary search) and a + * uniform grid (nearest point within a radius). + * points: [{ x, y, type, ... }] — kept by reference. + */ +export function createPointIndex(points, cellSize = 64) { + const n = points.length; + const xs = new Float64Array(n); + const ys = new Float64Array(n); + for (let i = 0; i < n; i++) { xs[i] = points[i].x; ys[i] = points[i].y; } + let byX = null, byY = null, sortedX = null, sortedY = null; + function ensureSorted() { + if (byX) return; + byX = new Uint32Array(n); byY = new Uint32Array(n); + for (let i = 0; i < n; i++) { byX[i] = i; byY[i] = i; } + byX.sort((a, b) => xs[a] - xs[b]); + byY.sort((a, b) => ys[a] - ys[b]); + sortedX = new Float64Array(n); sortedY = new Float64Array(n); + for (let i = 0; i < n; i++) { sortedX[i] = xs[byX[i]]; sortedY[i] = ys[byY[i]]; } + } + let grid = null; + function ensureGrid() { + if (grid) return; + grid = new Map(); + for (let i = 0; i < n; i++) { + if (!Number.isFinite(xs[i]) || !Number.isFinite(ys[i])) continue; + const k = _cellKey(Math.floor(xs[i] / cellSize), Math.floor(ys[i] / cellSize)); + let b = grid.get(k); + if (!b) { b = []; grid.set(k, b); } + b.push(i); + } + } + function lowerBound(arr, v) { + let lo = 0, hi = arr.length; + while (lo < hi) { const mid = (lo + hi) >>> 1; if (arr[mid] < v) lo = mid + 1; else hi = mid; } + return lo; + } + function aligned(sorted, order, v, tol, cb, maxVisit) { + let visited = 0; + for (let i = lowerBound(sorted, v - tol); i < sorted.length && sorted[i] <= v + tol; i++) { + if (++visited > maxVisit) break; + cb(order[i], points[order[i]]); + } + } + return { + points, + size: n, + /** Visit points with |p.x - x| <= tol (vertical alignment). */ + alignedX(x, tol, cb, maxVisit = 4000) { ensureSorted(); aligned(sortedX, byX, x, tol, cb, maxVisit); }, + /** Visit points with |p.y - y| <= tol (horizontal alignment). */ + alignedY(y, tol, cb, maxVisit = 4000) { ensureSorted(); aligned(sortedY, byY, y, tol, cb, maxVisit); }, + /** Nearest point within r of (x, y): { point, index, dist } or null. */ + nearest(x, y, r) { + if (!n || !(r > 0)) return null; + ensureGrid(); + const minCx = Math.floor((x - r) / cellSize), maxCx = Math.floor((x + r) / cellSize); + const minCy = Math.floor((y - r) / cellSize), maxCy = Math.floor((y + r) / cellSize); + let best = null; + for (let cx = minCx; cx <= maxCx; cx++) { + for (let cy = minCy; cy <= maxCy; cy++) { + const b = grid.get(_cellKey(cx, cy)); + if (!b) continue; + for (let k = 0; k < b.length; k++) { + const i = b[k]; + const d = Math.hypot(xs[i] - x, ys[i] - y); + // Strict < keeps the FIRST candidate on an exact tie (same rule + // as findNearestSnap), so specific labels pushed first win. + if (d <= r && (!best || d < best.dist || (d === best.dist && i < best.index))) { + best = { point: points[i], index: i, dist: d }; + } + } + } + } + return best; + }, + }; +} + +/** + * Segment index: uniform grid over segment bounding boxes; segments whose + * footprint exceeds `spanCap` cells go on an always-checked list. + * segments: [{ x1, y1, x2, y2, ... }]. + */ +export function createSegmentIndex(segments, cellSize = 64, spanCap = 256) { + const grid = new Map(); + const oversized = []; + for (let i = 0; i < segments.length; i++) { + const s = segments[i]; + if (![s.x1, s.y1, s.x2, s.y2].every(Number.isFinite)) continue; + const minCx = Math.floor(Math.min(s.x1, s.x2) / cellSize); + const maxCx = Math.floor(Math.max(s.x1, s.x2) / cellSize); + const minCy = Math.floor(Math.min(s.y1, s.y2) / cellSize); + const maxCy = Math.floor(Math.max(s.y1, s.y2) / cellSize); + if ((maxCx - minCx + 1) * (maxCy - minCy + 1) > spanCap) { oversized.push(i); continue; } + for (let cx = minCx; cx <= maxCx; cx++) { + for (let cy = minCy; cy <= maxCy; cy++) { + const k = _cellKey(cx, cy); + let b = grid.get(k); + if (!b) { b = []; grid.set(k, b); } + b.push(i); + } + } + } + return { + segments, + size: segments.length, + /** Segments whose bbox touches the square of radius r around (x, y). */ + near(x, y, r) { + const minCx = Math.floor((x - r) / cellSize), maxCx = Math.floor((x + r) / cellSize); + const minCy = Math.floor((y - r) / cellSize), maxCy = Math.floor((y + r) / cellSize); + const seen = new Set(); + const out = []; + for (let cx = minCx; cx <= maxCx; cx++) { + for (let cy = minCy; cy <= maxCy; cy++) { + const b = grid.get(_cellKey(cx, cy)); + if (!b) continue; + for (let k = 0; k < b.length; k++) { + if (!seen.has(b[k])) { seen.add(b[k]); out.push(segments[b[k]]); } + } + } + } + for (const i of oversized) if (!seen.has(i)) { seen.add(i); out.push(segments[i]); } + return out; + }, + }; +} + +// ─── Individual inferences ───────────────────────────────────────────────── + +/** + * Polar line through `start` when the start→cursor angle is within + * `toleranceDeg` of a multiple of `incrementDeg` (same rule as polarPass in + * snap-engine.js). Returns { line, angle, angleDiff } or null. + */ +export function polarLine(start, cursor, incrementDeg = 45, toleranceDeg = 3) { + const dx = cursor.x - start.x, dy = cursor.y - start.y; + if (Math.hypot(dx, dy) < 1e-6) return null; + const inc = (incrementDeg * Math.PI) / 180; + if (!(inc > 0)) return null; + const angle = Math.atan2(dy, dx); + const snapped = Math.round(angle / inc) * inc; + let diff = Math.abs(angle - snapped); + if (diff > Math.PI) diff = 2 * Math.PI - diff; + if (diff > (toleranceDeg * Math.PI) / 180) return null; + return { line: makeLine(start.x, start.y, Math.cos(snapped), Math.sin(snapped)), angle: snapped, angleDiff: diff }; +} + +/** + * H/V alignment of probes with indexed points. + * probes: [{ x, y, ox, oy }] — probe position and its offset from the cursor. + * Returns candidate guides (cursor space) for the nearest aligned point per + * probe and direction. + */ +export function findAlignments(probes, pointIndex, tol, reach, kind = 'tracking') { + const out = []; + if (!pointIndex || !pointIndex.size) return out; + for (const p of probes) { + // Vertical guide: same x. + let bestV = null; + pointIndex.alignedX(p.x, tol, (_i, s) => { + const along = Math.abs(s.y - p.y); + if (along > reach) return; + const d = Math.abs(s.x - p.x); + if (along <= tol && d <= tol) return; // that's a point snap, not tracking + if (!bestV || d < bestV.d - 1e-9 || (Math.abs(d - bestV.d) <= 1e-9 && along < bestV.along)) bestV = { s, d, along }; + }); + if (bestV) { + out.push({ + kind, line: makeLine(bestV.s.x - p.ox, 0, 0, 1), dist: bestV.d, + source: bestV.s, probe: p, dir: 'v', + }); + } + // Horizontal guide: same y. + let bestH = null; + pointIndex.alignedY(p.y, tol, (_i, s) => { + const along = Math.abs(s.x - p.x); + if (along > reach) return; + const d = Math.abs(s.y - p.y); + if (along <= tol && d <= tol) return; + if (!bestH || d < bestH.d - 1e-9 || (Math.abs(d - bestH.d) <= 1e-9 && along < bestH.along)) bestH = { s, d, along }; + }); + if (bestH) { + out.push({ + kind, line: makeLine(0, bestH.s.y - p.oy, 1, 0), dist: bestH.d, + source: bestH.s, probe: p, dir: 'h', + }); + } + } + return out; +} + +/** H/V tracking lines through acquired points (cursor only). */ +export function acquiredLines(acquired, cursor, tol) { + const out = []; + for (const a of acquired || []) { + const dv = Math.abs(cursor.x - a.x); + if (dv <= tol) out.push({ kind: 'acquired', line: makeLine(a.x, a.y, 0, 1), dist: dv, source: a, dir: 'v' }); + const dh = Math.abs(cursor.y - a.y); + if (dh <= tol) out.push({ kind: 'acquired', line: makeLine(a.x, a.y, 1, 0), dist: dh, source: a, dir: 'h' }); + } + return out; +} + +/** + * Extension lines: a probe lies on the infinite line of a segment, BEYOND one + * of its endpoints (on the segment itself is edge snap's job). + * segmentQuery(x, y, r) → segments near the probe. + */ +export function findExtensions(probes, segmentQuery, tol, reach, { minLength = 0, maxSegments = 400 } = {}) { + const out = []; + if (typeof segmentQuery !== 'function') return out; + for (const p of probes) { + const segs = segmentQuery(p.x, p.y, reach) || []; + let best = null; + const lim = Math.min(segs.length, maxSegments); + for (let i = 0; i < lim; i++) { + const s = segs[i]; + const sdx = s.x2 - s.x1, sdy = s.y2 - s.y1; + const len = Math.hypot(sdx, sdy); + if (!(len > Math.max(minLength, 1e-9))) continue; + const l = makeLine(s.x1, s.y1, sdx, sdy); + const d = distToLine(l, p.x, p.y); + if (d > tol) continue; + const t = (p.x - s.x1) * l.dx + (p.y - s.y1) * l.dy; + let beyond, end; + if (t < 0) { beyond = -t; end = { x: s.x1, y: s.y1 }; } + else if (t > len) { beyond = t - len; end = { x: s.x2, y: s.y2 }; } + else continue; // on the segment + if (beyond < tol || beyond > reach) continue; + if (!best || d < best.dist) { + best = { kind: 'extension', line: makeLine(l.px - p.ox, l.py - p.oy, l.dx, l.dy), dist: d, source: end, segment: s, probe: p }; + } + } + if (best) out.push(best); + } + return out; +} + +/** + * Parallel / perpendicular inference: the move direction start→cursor is + * within `angleTolDeg` of parallel or perpendicular to a nearby segment. + * Returns guides through `start`. + */ +export function findParallelPerpendicular(start, cursor, segments, angleTolDeg, { minMove = 0, maxSegments = 16 } = {}) { + const out = []; + const vx = cursor.x - start.x, vy = cursor.y - start.y; + const vlen = Math.hypot(vx, vy); + if (!(vlen > Math.max(minMove, 1e-9))) return out; + const tol = (angleTolDeg * Math.PI) / 180; + // Nearest segments first (bounded). + const ranked = []; + for (const s of segments || []) { + const sdx = s.x2 - s.x1, sdy = s.y2 - s.y1; + const len = Math.hypot(sdx, sdy); + if (!(len > 1e-9)) continue; + let t = ((cursor.x - s.x1) * sdx + (cursor.y - s.y1) * sdy) / (len * len); + t = Math.max(0, Math.min(1, t)); + const d = Math.hypot(cursor.x - (s.x1 + t * sdx), cursor.y - (s.y1 + t * sdy)); + ranked.push({ s, d, ux: sdx / len, uy: sdy / len }); + } + ranked.sort((a, b) => a.d - b.d); + const mvA = Math.atan2(vy, vx); + for (const r of ranked.slice(0, maxSegments)) { + const segA = Math.atan2(r.uy, r.ux); + let diff = Math.abs(mvA - segA) % Math.PI; // 0..π + if (diff > Math.PI / 2) diff = Math.PI - diff; // 0..π/2: 0 = parallel, π/2 = perpendicular + if (diff <= tol) { + // Direction along the segment, oriented with the move. + const sgn = (vx * r.ux + vy * r.uy) >= 0 ? 1 : -1; + const line = makeLine(start.x, start.y, sgn * r.ux, sgn * r.uy); + out.push({ kind: 'parallel', line, dist: distToLine(line, cursor.x, cursor.y), angleDiff: diff, segment: r.s, refDist: r.d }); + } else if (Math.PI / 2 - diff <= tol) { + const px = -r.uy, py = r.ux; + const sgn = (vx * px + vy * py) >= 0 ? 1 : -1; + const line = makeLine(start.x, start.y, sgn * px, sgn * py); + out.push({ kind: 'perpendicular', line, dist: distToLine(line, cursor.x, cursor.y), angleDiff: Math.PI / 2 - diff, segment: r.s, refDist: r.d }); + } + } + // Closest reference line first; one guide per direction is enough. + out.sort((a, b) => a.refDist - b.refDist); + const uniq = []; + for (const g of out) if (!uniq.some(u => _sameLine(u.line, g.line, 1e-6))) uniq.push(g); + return uniq; +} + +// ─── Acquisition (hover a point briefly) ─────────────────────────────────── + +/** + * Tracking-point acquisition: hovering the same point for `delayMs` acquires + * it; hovering an acquired point again for `delayMs` releases it. A point is + * acted on at most once per hover (moving away re-arms it). At most `max` + * points are kept (oldest dropped). + */ +export function createAcquirer({ delayMs = 400, max = 4, eps = 1e-6 } = {}) { + let list = []; + let hover = null; // { x, y, since, done } + const same = (a, b) => a && b && Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps; + return { + /** Feed the currently hovered point (or null). Returns { changed, pendingMs }. */ + update(pt, now) { + if (!pt) { hover = null; return { changed: false, pendingMs: 0 }; } + if (!hover || !same(hover, pt)) hover = { x: pt.x, y: pt.y, type: pt.type, since: now, done: false }; + if (hover.done) return { changed: false, pendingMs: 0 }; + const left = delayMs - (now - hover.since); + if (left > 0) return { changed: false, pendingMs: left }; + hover.done = true; + const idx = list.findIndex(a => same(a, hover)); + if (idx >= 0) list.splice(idx, 1); + else { + list.push({ x: hover.x, y: hover.y, type: hover.type }); + if (list.length > max) list = list.slice(list.length - max); + } + return { changed: true, pendingMs: 0 }; + }, + list() { return list.slice(); }, + clear() { list = []; hover = null; }, + }; +} + +// ─── The combined inference ──────────────────────────────────────────────── + +// When two guides describe the same line, keep the more meaningful one. +const DEDUPE_RANK = { locked: 0, acquired: 1, tracking: 2, extension: 3, parallel: 4, perpendicular: 4, axis: 5, polar: 6 }; + +/** + * Infer the cursor position of a move. + * + * opts: + * start, cursor base point and current cursor (page space) + * probes [{x,y,ox,oy}] snap points of the moving selection + * at their CURRENT position (cursor included as ox=oy=0 + * automatically) + * pointIndex createPointIndex of STATIC points (selection excluded) + * segmentQuery(x,y,r) static segments near a point + * acquired acquired tracking points + * tol distance tolerance (page units) + * reach max distance along a guide for tracking/extension + * angleTolDeg parallel/perpendicular angular tolerance + * polar null | { incrementDeg, toleranceDeg } + * enable { tracking, extension, parallel } booleans + * lockLine optional line the result must lie on (Shift lock, + * X/Y axis lock) + * extensionReach max distance beyond a line's endpoint (default reach) + * extensionProbes max probes used for extension (default 5) + * + * Returns null or { x, y, kind, priority, dist, guides:[...], lockable }. + */ +export function inferMove(opts) { + const { + start, cursor, pointIndex, segmentQuery, acquired = [], + tol, reach = Infinity, angleTolDeg = 2, polar = null, + enable = { tracking: true, extension: true, parallel: true }, + lockLine = null, extensionProbes = 5, + } = opts; + const extensionReach = opts.extensionReach ?? reach; + if (!start || !cursor || !(tol > 0)) return null; + const probes = [{ x: cursor.x, y: cursor.y, ox: 0, oy: 0, cursor: true }, ...(opts.probes || [])]; + + // Lines are gathered with a relaxed tolerance when locked: they only have + // to cross the lock line near the cursor. + const gTol = lockLine ? tol * 1.5 : tol; + let guides = []; + if (enable.tracking) { + guides.push(...findAlignments(probes, pointIndex, gTol, reach, 'tracking')); + guides.push(...acquiredLines(acquired, cursor, gTol)); + for (const [dx, dy, dir] of [[1, 0, 'h'], [0, 1, 'v']]) { + const line = makeLine(start.x, start.y, dx, dy); + const d = distToLine(line, cursor.x, cursor.y); + if (d <= gTol && Math.hypot(cursor.x - start.x, cursor.y - start.y) > tol) { + guides.push({ kind: 'axis', line, dist: d, source: { x: start.x, y: start.y }, dir }); + } + } + } + if (polar) { + const pl = polarLine(start, cursor, polar.incrementDeg, polar.toleranceDeg); + if (pl) guides.push({ kind: 'polar', line: pl.line, dist: distToLine(pl.line, cursor.x, cursor.y), angle: pl.angle, source: { x: start.x, y: start.y } }); + } + if (enable.extension) { + guides.push(...findExtensions(probes.slice(0, Math.max(1, extensionProbes)), segmentQuery, gTol, extensionReach)); + } + if (enable.parallel && typeof segmentQuery === 'function') { + const near = segmentQuery(cursor.x, cursor.y, Math.min(reach, tol * 30)) || []; + const pp = findParallelPerpendicular(start, cursor, near, angleTolDeg, { minMove: tol * 2 }); + guides.push(...pp); + } + + // Dedupe identical lines (e.g. axis vs polar at 0°, parallel to a horizontal line). + guides.sort((a, b) => (DEDUPE_RANK[a.kind] ?? 9) - (DEDUPE_RANK[b.kind] ?? 9) || a.dist - b.dist); + const uniq = []; + for (const g of guides) { + if (!g.line) continue; + if (uniq.some(u => _sameLine(u.line, g.line, tol * 0.05))) continue; + uniq.push(g); + } + guides = uniq; + + if (lockLine) { + const foot = projectOnLine(lockLine, cursor.x, cursor.y); + let best = null; + for (const g of guides) { + const X = intersectLines(lockLine, g.line); + if (!X) continue; + const d = Math.hypot(X.x - foot.x, X.y - foot.y); + if (d <= tol * 1.5 && (!best || d < best.dist)) best = { x: X.x, y: X.y, kind: 'intersection', dist: d, guides: [g] }; + } + const lockedGuide = { kind: 'locked', line: lockLine, dist: 0, source: lockLine.source || null }; + if (best) { + return { ...best, priority: INFERENCE_PRIORITY.intersection, guides: [lockedGuide, ...best.guides], lockable: lockLine }; + } + return { x: foot.x, y: foot.y, kind: 'locked', priority: INFERENCE_PRIORITY.locked, dist: distToLine(lockLine, cursor.x, cursor.y), guides: [lockedGuide], lockable: lockLine }; + } + + const active = guides.filter(g => g.dist <= tol || g.kind === 'polar').sort((a, b) => a.dist - b.dist).slice(0, 12); + if (active.length === 0) return null; + + // Intersection of two guides near the cursor. + let bestX = null; + for (let i = 0; i < active.length; i++) { + for (let j = i + 1; j < active.length; j++) { + const X = intersectLines(active[i].line, active[j].line); + if (!X) continue; + const d = Math.hypot(X.x - cursor.x, X.y - cursor.y); + if (d <= tol * 1.5 && (!bestX || d < bestX.dist)) bestX = { x: X.x, y: X.y, dist: d, guides: [active[i], active[j]] }; + } + } + if (bestX) { + return { ...bestX, kind: 'intersection', priority: INFERENCE_PRIORITY.intersection, lockable: null }; + } + + // Single guide: highest priority, then nearest. + let best = null; + for (const g of active) { + const pr = INFERENCE_PRIORITY[g.kind] ?? 0; + if (!best || pr > best.pr || (pr === best.pr && g.dist < best.g.dist)) best = { g, pr }; + } + const f = projectOnLine(best.g.line, cursor.x, cursor.y); + return { + x: f.x, y: f.y, kind: best.g.kind, priority: best.pr, dist: best.g.dist, + guides: [best.g], lockable: best.g.line, + }; +} diff --git a/open-pdf-studio/js/tools/inference-engine.test.mjs b/open-pdf-studio/js/tools/inference-engine.test.mjs new file mode 100644 index 000000000..a86a3eb19 --- /dev/null +++ b/open-pdf-studio/js/tools/inference-engine.test.mjs @@ -0,0 +1,217 @@ +// Inference-geometrie van het verplaatsen (#519): uitlijnlijnen, verworven +// punten en hun snijpunten, verlengden, evenwijdig/loodrecht met tolerantie, +// pool rond het basispunt, de prioriteit tegenover objectsnap en de +// begrensde indexen. Pure module: geen app-state, geen stubs. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { + createPointIndex, createSegmentIndex, createAcquirer, inferMove, findAlignments, + findExtensions, findParallelPerpendicular, polarLine, intersectLines, makeLine, + resolveSnapPriority, snapPriority, isPointSnapType, screenAxis, INFERENCE_PRIORITY, +} = await import('./inference-engine.js'); + +const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps; +const ALLES = { tracking: true, extension: true, parallel: true }; +const GEEN = { tracking: false, extension: false, parallel: false }; + +function basis(over = {}) { + return { + start: { x: 0, y: 0 }, cursor: { x: 0, y: 0 }, probes: [], + pointIndex: createPointIndex([]), segmentQuery: () => [], acquired: [], + tol: 4, reach: 1000, angleTolDeg: 2, polar: null, enable: ALLES, ...over, + }; +} + +test('uitlijnen: de cursor staat (bijna) recht onder een eindpunt → verticale hulplijn', () => { + const idx = createPointIndex([{ x: 100, y: 20, type: 'endpoint' }]); + const r = inferMove(basis({ start: { x: 500, y: 500 }, cursor: { x: 102, y: 300 }, pointIndex: idx })); + assert.equal(r.kind, 'tracking'); + assert.ok(near(r.x, 100) && near(r.y, 300), JSON.stringify(r)); + assert.equal(r.guides[0].source.x, 100); +}); + +test('uitlijnen buiten de tolerantie of het bereik geeft niets', () => { + const idx = createPointIndex([{ x: 100, y: 20, type: 'endpoint' }]); + assert.equal(inferMove(basis({ start: { x: 500, y: 500 }, cursor: { x: 110, y: 300 }, pointIndex: idx })), null); + assert.equal(inferMove(basis({ start: { x: 500, y: 500 }, cursor: { x: 101, y: 300 }, pointIndex: idx, reach: 100 })), null); +}); + +test('een snappunt van de BEWEGENDE selectie lijnt uit; de verschuiving corrigeert de cursor', () => { + // Eigen hoekpunt ligt 50 rechts van de cursor; statisch punt op x = 250. + const idx = createPointIndex([{ x: 250, y: 0, type: 'corner' }]); + const cursor = { x: 198, y: 400 }; + const probes = [{ x: cursor.x + 50, y: cursor.y, ox: 50, oy: 0 }]; + const r = inferMove(basis({ start: { x: 600, y: 600 }, cursor, probes, pointIndex: idx })); + assert.equal(r.kind, 'tracking'); + // Cursor 200 → het eigen punt landt precies op x = 250. + assert.ok(near(r.x, 200), JSON.stringify(r)); +}); + +test('twee uitlijningen tegelijk → snijpunt (horizontaal van het ene, verticaal van het andere punt)', () => { + const idx = createPointIndex([{ x: 100, y: 0, type: 'endpoint' }, { x: 0, y: 200, type: 'midpoint' }]); + const r = inferMove(basis({ start: { x: 900, y: 900 }, cursor: { x: 102, y: 197 }, pointIndex: idx })); + assert.equal(r.kind, 'intersection'); + assert.ok(near(r.x, 100) && near(r.y, 200), JSON.stringify(r)); + assert.equal(r.priority, INFERENCE_PRIORITY.intersection); +}); + +test('verworven punten geven H/V-volglijnen; twee verworven punten geven hun snijpunt', () => { + const acquired = [{ x: 50, y: 10 }, { x: 300, y: 120 }]; + const r = inferMove(basis({ start: { x: 900, y: 900 }, cursor: { x: 52, y: 118 }, acquired })); + assert.equal(r.kind, 'intersection'); + assert.ok(near(r.x, 50) && near(r.y, 120), JSON.stringify(r)); + // Zonder volglijnen (instelling uit) doen ze niets. + assert.equal(inferMove(basis({ start: { x: 900, y: 900 }, cursor: { x: 52, y: 118 }, acquired, enable: GEEN })), null); +}); + +test('verwerven: even stilstaan op een punt verwerft het, nogmaals stilstaan laat het los', () => { + const acq = createAcquirer({ delayMs: 300, max: 2 }); + const p = { x: 5, y: 5, type: 'endpoint' }; + assert.deepEqual(acq.update(p, 0), { changed: false, pendingMs: 300 }); + assert.equal(acq.update(p, 200).changed, false); + assert.equal(acq.update(p, 310).changed, true); + assert.equal(acq.list().length, 1); + // Blijft erop staan: niet meteen weer loslaten. + assert.equal(acq.update(p, 900).changed, false); + acq.update(null, 950); + acq.update(p, 1000); + assert.equal(acq.update(p, 1400).changed, true); + assert.equal(acq.list().length, 0); + // Maximaal `max`: het oudste valt af. + for (const [i, t] of [[1, 2000], [2, 3000], [3, 4000]]) { + acq.update({ x: i, y: i }, t); acq.update({ x: i, y: i }, t + 301); + } + assert.deepEqual(acq.list().map(a => a.x), [2, 3]); +}); + +test('verlengde: voorbij het eindpunt van een lijn, niet óp de lijn', () => { + const segs = createSegmentIndex([{ x1: 0, y1: 0, x2: 100, y2: 100 }]); + const q = (x, y, r) => segs.near(x, y, r); + const r = inferMove(basis({ start: { x: 500, y: 0 }, cursor: { x: 152, y: 149 }, segmentQuery: q, enable: { ...GEEN, extension: true } })); + assert.equal(r.kind, 'extension'); + assert.ok(near(r.x, 150.5) && near(r.y, 150.5), JSON.stringify(r)); + assert.deepEqual(r.guides[0].source, { x: 100, y: 100 }); + // Op het lijnstuk zelf: geen verlengde (dat is de randsnap). + assert.equal(inferMove(basis({ start: { x: 500, y: 0 }, cursor: { x: 51, y: 50 }, segmentQuery: q, enable: { ...GEEN, extension: true } })), null); + // Verder dan het bereik voorbij het eindpunt: niets. + assert.deepEqual(findExtensions([{ x: 800, y: 800, ox: 0, oy: 0 }], q, 4, 100), []); +}); + +test('verlengde van een wandvlak via een eigen hoekpunt van de selectie', () => { + // Wandvlak horizontaal op y = 40 van x 0..200; het eigen hoekpunt ligt 30 onder de cursor. + const segs = createSegmentIndex([{ x1: 0, y1: 40, x2: 200, y2: 40, face: true }]); + const cursor = { x: 260, y: 12 }; + const probes = [{ x: 260, y: 42, ox: 0, oy: 30 }]; + const r = inferMove(basis({ start: { x: 900, y: 900 }, cursor, probes, segmentQuery: (x, y, rr) => segs.near(x, y, rr), enable: { ...GEEN, extension: true } })); + assert.equal(r.kind, 'extension'); + assert.ok(near(r.y, 10), JSON.stringify(r)); // hoekpunt landt op y = 40 +}); + +test('evenwijdig en loodrecht binnen de hoektolerantie, niet erbuiten', () => { + const seg = { x1: 0, y1: 0, x2: 100, y2: 50 }; // ~26.57° + const ang = Math.atan2(50, 100); + const start = { x: 0, y: 200 }; + const evenwijdig = { x: start.x + 100 * Math.cos(ang + 0.02), y: start.y + 100 * Math.sin(ang + 0.02) }; // ~1.1° + let r = findParallelPerpendicular(start, evenwijdig, [seg], 2); + assert.equal(r.length, 1); + assert.equal(r[0].kind, 'parallel'); + const buiten = { x: start.x + 100 * Math.cos(ang + 0.06), y: start.y + 100 * Math.sin(ang + 0.06) }; // ~3.4° + assert.deepEqual(findParallelPerpendicular(start, buiten, [seg], 2), []); + const loodrecht = { x: start.x + 100 * Math.cos(ang + Math.PI / 2 - 0.015), y: start.y + 100 * Math.sin(ang + Math.PI / 2 - 0.015) }; + r = findParallelPerpendicular(start, loodrecht, [seg], 2); + assert.equal(r[0].kind, 'perpendicular'); + // In inferMove: de cursor wordt op de hulplijn door het basispunt gelegd. + const res = inferMove(basis({ start, cursor: evenwijdig, segmentQuery: () => [seg], tol: 4, enable: { ...GEEN, parallel: true } })); + assert.equal(res.kind, 'parallel'); + const vx = res.x - start.x, vy = res.y - start.y; + assert.ok(Math.abs(vx * 50 - vy * 100) < 1e-6, 'richting exact evenwijdig'); + assert.equal(res.guides[0].segment, seg); +}); + +test('pool tijdens verplaatsen: increment en tolerantie van de poolinstellingen rond het basispunt', () => { + const start = { x: 100, y: 100 }; + const c = { x: 100 + 100 * Math.cos(Math.PI / 4 + 0.03), y: 100 + 100 * Math.sin(Math.PI / 4 + 0.03) }; + assert.ok(polarLine(start, c, 45, 3)); + assert.equal(polarLine(start, c, 45, 1), null); + const r = inferMove(basis({ start, cursor: c, polar: { incrementDeg: 45, toleranceDeg: 3 }, enable: GEEN })); + assert.equal(r.kind, 'polar'); + assert.ok(near(r.x - start.x, r.y - start.y), 'op de 45°-straal'); + // Uit: geen poolhulplijn. + assert.equal(inferMove(basis({ start, cursor: c, polar: null, enable: GEEN })), null); +}); + +test('as door het basispunt; Shift-vergrendeling houdt de lijn vast en snijdt met andere hulplijnen', () => { + const start = { x: 0, y: 0 }; + let r = inferMove(basis({ start, cursor: { x: 300, y: 3 }, enable: { ...GEEN, tracking: true } })); + assert.equal(r.kind, 'axis'); + assert.ok(near(r.y, 0)); + const lock = r.lockable; + // Cursor ver weg van de lijn: blijft erop. + r = inferMove(basis({ start, cursor: { x: 250, y: 80 }, lockLine: lock, enable: GEEN })); + assert.equal(r.kind, 'locked'); + assert.ok(near(r.x, 250) && near(r.y, 0)); + // Vergrendeld + een verticale uitlijning op x = 240 → snijpunt. + const idx = createPointIndex([{ x: 240, y: -500, type: 'endpoint' }]); + r = inferMove(basis({ start, cursor: { x: 243, y: 80 }, lockLine: lock, pointIndex: idx, enable: { ...GEEN, tracking: true } })); + assert.equal(r.kind, 'intersection'); + assert.ok(near(r.x, 240) && near(r.y, 0), JSON.stringify(r)); +}); + +test('prioriteit: puntsnap > snijpunt > volglijn > pool > rand > raster', () => { + assert.ok(isPointSnapType('endpoint') && isPointSnapType('vertex') && isPointSnapType('startpoint')); + assert.ok(!isPointSnapType('edge') && !isPointSnapType('grid') && !isPointSnapType('nearest')); + const P = INFERENCE_PRIORITY; + assert.ok(snapPriority('corner') > P.intersection); + assert.ok(P.intersection > P.tracking && P.tracking > P.polar && P.polar > snapPriority('edge') && snapPriority('edge') > snapPriority('grid')); + const win = resolveSnapPriority([ + { id: 'rand', priority: snapPriority('edge'), dist: 0.1 }, + { id: 'volg', priority: P.tracking, dist: 3 }, + { id: 'eind', priority: snapPriority('endpoint'), dist: 5 }, + ]); + assert.equal(win.id, 'eind'); + assert.equal(resolveSnapPriority([{ id: 'a', priority: 60, dist: 2 }, { id: 'b', priority: 60, dist: 1 }]).id, 'b'); + assert.equal(resolveSnapPriority([]), null); +}); + +test('dubbele lijnen: de as door het basispunt wint van de pool op 0°', () => { + const r = inferMove(basis({ start: { x: 0, y: 0 }, cursor: { x: 200, y: 2 }, polar: { incrementDeg: 90, toleranceDeg: 3 }, enable: { ...GEEN, tracking: true } })); + assert.equal(r.kind, 'axis'); + assert.equal(r.guides.length, 1); +}); + +test('schermas volgt een gedraaide weergave', () => { + assert.equal(screenAxis(1, 0, false), 'x'); + assert.equal(screenAxis(0, 1, false), 'y'); + assert.equal(screenAxis(1, 0, true), 'y'); + assert.equal(screenAxis(1, 1, false), null); +}); + +test('snijpunt van lijnen; evenwijdige lijnen snijden niet', () => { + const a = makeLine(0, 0, 1, 0), b = makeLine(5, -5, 0, 1); + assert.deepEqual(intersectLines(a, b), { x: 5, y: 0 }); + assert.equal(intersectLines(a, makeLine(0, 3, 1, 0)), null); +}); + +test('indexen: uitlijnen via binair zoeken, dichtstbijzijnde punt via raster, segmenten lokaal', () => { + const pts = []; + for (let i = 0; i < 20000; i++) pts.push({ x: (i * 7919) % 5000, y: (i * 104729) % 5000 }); + const idx = createPointIndex(pts); + let n = 0; + idx.alignedX(1234, 0.5, () => { n++; }); + assert.equal(n, pts.filter(p => Math.abs(p.x - 1234) <= 0.5).length); + const best = idx.nearest(2500, 2500, 200); + const brute = pts.map(p => Math.hypot(p.x - 2500, p.y - 2500)).filter(d => d <= 200).sort((a, b) => a - b)[0]; + assert.ok(best && near(best.dist, brute)); + const segs = createSegmentIndex([{ x1: 0, y1: 0, x2: 10, y2: 0 }, { x1: 4000, y1: 4000, x2: 4010, y2: 4000 }, { x1: 0, y1: 0, x2: 50000, y2: 50000 }]); + const gevonden = segs.near(5, 5, 20); + assert.equal(gevonden.length, 2); // de lokale + de paginabrede + assert.ok(!gevonden.some(s => s.x1 === 4000)); +}); + +test('uitlijnen meldt per probe het dichtstbij uitgelijnde punt, niet een puntsnap', () => { + const idx = createPointIndex([{ x: 10, y: 0 }, { x: 10, y: 90 }, { x: 10, y: 101 }]); + const g = findAlignments([{ x: 11, y: 100, ox: 0, oy: 0 }], idx, 3, 1000); + const v = g.find(x => x.dir === 'v'); + assert.equal(v.source.y, 90); // (10,101) ligt binnen tol: dat is een puntsnap +}); diff --git a/open-pdf-studio/js/tools/inference-overlay.js b/open-pdf-studio/js/tools/inference-overlay.js new file mode 100644 index 000000000..cb04aec7b --- /dev/null +++ b/open-pdf-studio/js/tools/inference-overlay.js @@ -0,0 +1,119 @@ +// Overlay of the move inference (#519): dashed guide lines, markers and the +// tooltip naming the inference. The move session publishes a plain overlay +// description here; both render paths (single page and continuous) draw it +// inside their redraw so a viewport re-render can't wipe it. +// +// Deliberately light on imports (no snap-engine, no i18n): rendering.js +// imports this module, and the texts arrive already translated. + +import { zetRechtopRond } from '../pdf/weergave-ruimte.js'; + +let _overlay = null; + +/** + * overlay: null | { + * page, + * lines: [{ x1, y1, x2, y2, color, dash?: boolean, width?: number }], + * markers: [{ x, y, kind: 'acquired'|'source'|'intersection'|'parallel'|'perpendicular', color, angle? }], + * label: { x, y, text, color } | null, + * } — all in page (app) coordinates. + */ +export function setMoveInferenceOverlay(overlay) { + _overlay = overlay || null; +} + +export function getMoveInferenceOverlay() { + return _overlay; +} + +export function clearMoveInferenceOverlay() { + _overlay = null; +} + +function _marker(ctx, m, scale) { + const s = 4 / scale; + ctx.strokeStyle = m.color; + ctx.fillStyle = m.color; + ctx.lineWidth = 1.2 / scale; + ctx.beginPath(); + switch (m.kind) { + case 'acquired': // small "+" + ctx.moveTo(m.x - s, m.y); ctx.lineTo(m.x + s, m.y); + ctx.moveTo(m.x, m.y - s); ctx.lineTo(m.x, m.y + s); + ctx.stroke(); + break; + case 'intersection': // "x" + ctx.moveTo(m.x - s, m.y - s); ctx.lineTo(m.x + s, m.y + s); + ctx.moveTo(m.x + s, m.y - s); ctx.lineTo(m.x - s, m.y + s); + ctx.stroke(); + break; + case 'parallel': { // "//" across the reference line + const a = m.angle || 0; + const ux = Math.cos(a), uy = Math.sin(a); + const nx = -uy, ny = ux; + for (const k of [-1, 1]) { + const cx = m.x + ux * k * s * 0.6, cy = m.y + uy * k * s * 0.6; + ctx.moveTo(cx - nx * s + ux * s * 0.5, cy - ny * s + uy * s * 0.5); + ctx.lineTo(cx + nx * s - ux * s * 0.5, cy + ny * s - uy * s * 0.5); + } + ctx.stroke(); + break; + } + case 'perpendicular': { // right-angle mark in the corner + const a = m.angle || 0; + const ux = Math.cos(a), uy = Math.sin(a); + const nx = -uy, ny = ux; + const d = s * 1.6; + ctx.moveTo(m.x + ux * d, m.y + uy * d); + ctx.lineTo(m.x + ux * d + nx * d, m.y + uy * d + ny * d); + ctx.lineTo(m.x + nx * d, m.y + ny * d); + ctx.stroke(); + break; + } + default: // 'source': small dot on the referenced point + ctx.arc(m.x, m.y, 2 / scale, 0, Math.PI * 2); + ctx.fill(); + } +} + +/** Draw the published overlay when it belongs to `pageNum`. ctx is in page space. */ +export function drawMoveInferenceOverlay(ctx, pageNum, scale) { + const o = _overlay; + if (!o || (o.page != null && pageNum != null && o.page !== pageNum)) return; + const sc = scale > 0 ? scale : 1; + ctx.save(); + for (const l of o.lines || []) { + ctx.strokeStyle = l.color; + ctx.lineWidth = (l.width || 0.8) / sc; + ctx.setLineDash(l.dash === false ? [] : [5 / sc, 4 / sc]); + ctx.beginPath(); + ctx.moveTo(l.x1, l.y1); + ctx.lineTo(l.x2, l.y2); + ctx.stroke(); + } + ctx.setLineDash([]); + for (const m of o.markers || []) _marker(ctx, m, sc); + const lb = o.label; + if (lb && lb.text) { + // Upright on screen, also in a rotated view (#200). + zetRechtopRond(ctx, lb.x, lb.y); + const fontSize = 11 / sc; + ctx.font = `${fontSize}px Arial`; + const padX = 4 / sc, padY = 3 / sc; + const tw = ctx.measureText(lb.text).width; + const tx = lb.x + 14 / sc; + // Above-right of the cursor: the snap label sits just below it and the + // typed-distance field below-right. + const ty = lb.y - 16 / sc; + ctx.fillStyle = 'rgba(255, 255, 255, 0.94)'; + ctx.fillRect(tx - padX, ty - fontSize, tw + padX * 2, fontSize + padY * 2); + ctx.strokeStyle = lb.color || '#333333'; + ctx.lineWidth = 0.6 / sc; + ctx.strokeRect(tx - padX, ty - fontSize, tw + padX * 2, fontSize + padY * 2); + ctx.fillStyle = '#222222'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(lb.text, tx, ty); + } + ctx.restore(); +} diff --git a/open-pdf-studio/js/tools/move-inference-i18n.test.mjs b/open-pdf-studio/js/tools/move-inference-i18n.test.mjs new file mode 100644 index 000000000..d41d32411 --- /dev/null +++ b/open-pdf-studio/js/tools/move-inference-i18n.test.mjs @@ -0,0 +1,49 @@ +// Hulplijnen bij verplaatsen (#519): elke taal heeft de voorkeursteksten en +// de namen van de hulplijnen, echt vertaald. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync, readdirSync } from 'node:fs'; + +const LOCALES = new URL('../i18n/locales/', import.meta.url); +const lees = (taal, ns) => JSON.parse(readFileSync(new URL(`${taal}/${ns}.json`, LOCALES), 'utf8').replace(/\r\n/g, '\n')); + +const VOORKEUR = ['moveInference', 'moveInferenceTracking', 'moveInferenceExtension', 'moveInferenceParallel', 'moveInferenceHint']; +const NAMEN = ['onAxisX', 'onAxisY', 'fromPoint', 'extension', 'parallel', 'perpendicular', 'intersection', 'polar', 'locked']; + +// Woorden die in die taal echt gelijk zijn aan het Engels. +const GELIJK_AAN_ENGELS = new Set(['Polar', 'Parallel', 'Perpendicular', 'Intersection']); + +function teksten(taal) { + const p = lees(taal, 'preferences').behavior || {}; + const c = lees(taal, 'common').moveInference || {}; + const uit = {}; + for (const k of VOORKEUR) uit[`behavior.${k}`] = p[k]; + for (const k of NAMEN) uit[`moveInference.${k}`] = c[k]; + return uit; +} + +test('alle talen hebben de sleutels, niet leeg', () => { + const talen = readdirSync(LOCALES); + assert.ok(talen.length >= 40); + for (const taal of talen) { + for (const [k, v] of Object.entries(teksten(taal))) { + assert.ok(typeof v === 'string' && v.trim().length > 0, `${taal} ${k} ontbreekt`); + } + } +}); + +test('geen taal kopieert de Engelse tekst, en G / mv, Shift en Tab blijven staan', () => { + const en = teksten('en'); + const fout = []; + for (const taal of readdirSync(LOCALES)) { + if (taal === 'en') continue; + const t = teksten(taal); + for (const [k, v] of Object.entries(en)) { + if (t[k] === v && !GELIJK_AAN_ENGELS.has(v)) fout.push(`${taal} ${k}: ${v}`); + } + if (!t['behavior.moveInference'].includes('G / mv')) fout.push(`${taal} behavior.moveInference mist "G / mv"`); + if (!t['behavior.moveInferenceHint'].includes('Shift') && !/Maj|Mayús|Maiusc/.test(t['behavior.moveInferenceHint'])) fout.push(`${taal} hint mist Shift`); + if (!t['behavior.moveInferenceHint'].includes('Tab')) fout.push(`${taal} hint mist Tab`); + } + assert.deepEqual(fout, []); +}); diff --git a/open-pdf-studio/js/tools/move-inference.js b/open-pdf-studio/js/tools/move-inference.js new file mode 100644 index 000000000..e3ff56d25 --- /dev/null +++ b/open-pdf-studio/js/tools/move-inference.js @@ -0,0 +1,285 @@ +// Move inference session (#519): glue between the move mode (g-move-mode.js), +// the snap candidates (snap-engine.js / PDF content) and the pure inference +// engine (inference-engine.js). Builds the bounded candidate indexes once per +// session, runs the inference per mousemove, tracks acquired points and +// publishes the overlay (inference-overlay.js). + +import { + createPointIndex, createSegmentIndex, createAcquirer, inferMove, makeLine, + screenAxis, isPointSnapType, snapPriority, resolveSnapPriority, INFERENCE_PRIORITY, +} from './inference-engine.js'; +import { getEdgeSegments } from './snap-engine.js'; +import { getPdfEdgeSegmentsNear } from './pdf-snap-extractor.js'; +import { computeWallShape } from '../annotations/rendering/walls.js'; +import { findMeasureScale } from '../annotations/measurement.js'; +import { setMoveInferenceOverlay, clearMoveInferenceOverlay } from './inference-overlay.js'; +import i18next from '../i18n/config.js'; + +export { isPointSnapType, snapPriority, resolveSnapPriority, INFERENCE_PRIORITY }; + +// Tolerances in SCREEN pixels (divided by the zoom where used). +const TRACK_TOL_PX = 6; +const REACH_PX = 1500; // max distance along a tracking guide +const EXTENSION_REACH_PX = 400; // max distance beyond a line's endpoint +const ACQUIRE_MS = 400; +const MAX_ACQUIRED = 4; +const MAX_OWN_PROBES = 12; +const MAX_PDF_SEGMENTS = 400; // per query; the PDF index is local already + +export const GUIDE_COLORS = Object.freeze({ + x: '#e53935', // screen X (horizontal) + y: '#2e9d3a', // screen Y (vertical) + parallel: '#d000d0', // parallel / perpendicular + extension: '#ff8c00', + polar: '#cc66cc', + intersection: '#1565c0', +}); + +const OWN_TYPE_RANK = { corner: 0, endpoint: 0, vertex: 0, startpoint: 0, center: 1, quadrant: 2, midpoint: 2 }; + +/** + * Pick at most `max` snap points of the moving selection as tracking probes: + * corners/endpoints first, then centres, then midpoints; duplicates dropped. + */ +export function selectOwnProbes(ownPoints, max = MAX_OWN_PROBES) { + const sorted = (ownPoints || []).slice().sort((a, b) => (OWN_TYPE_RANK[a.type] ?? 3) - (OWN_TYPE_RANK[b.type] ?? 3)); + const out = []; + for (const p of sorted) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + if (out.some(q => Math.abs(q.x - p.x) < 1e-6 && Math.abs(q.y - p.y) < 1e-6)) continue; + out.push(p); + if (out.length >= max) break; + } + return out; +} + +/** + * Static segments of the page for extension / parallel inference: annotation + * edges (lines, polylines, rectangles …) plus the FACES of walls (the band + * outline, not only the axis). Annotations in `excludeIds` (the moving + * selection) are skipped — moving never infers from itself. + */ +export function collectStaticSegments(annotations, page, excludeIds) { + const segs = []; + const onPage = (annotations || []).filter(a => a && a.page === page && a.type !== 'draw' + && !(excludeIds && excludeIds.has(a.id))); + const walls = (annotations || []).filter(a => a && a.page === page && a.type === 'wall'); + for (const ann of onPage) { + try { + for (const s of getEdgeSegments(ann)) segs.push({ x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2, annotation: ann }); + } catch (_) { /* best-effort */ } + if (ann.type === 'wall') { + try { + const poly = computeWallShape(ann, walls)?.poly; + if (poly && poly.length > 1) { + for (let i = 0; i < poly.length; i++) { + const p = poly[i], q = poly[(i + 1) % poly.length]; + segs.push({ x1: p.x, y1: p.y, x2: q.x, y2: q.y, annotation: ann, face: true }); + } + } + } catch (_) { /* best-effort */ } + } + } + return segs; +} + +function _t(key, fallback, opts) { + try { + const v = i18next?.t?.(`common:moveInference.${key}`, opts); + if (typeof v === 'string' && v && !v.endsWith(`moveInference.${key}`)) return v; + } catch (_) { /* i18n not ready */ } + return fallback; +} + +/** + * Create the inference session for one move. + * staticIndex createPointIndex over the static snap points (selection excluded) + * annotations, page, excludeIds + * ownPoints snap points of the selection at their ORIGINAL position + * start base point of the move + * onAcquire callback when an acquisition timer fires (re-evaluate + redraw) + */ +export function createMoveInferenceSession({ + staticIndex, annotations, page, excludeIds, ownPoints, start, onAcquire, pdfSegments = true, +}) { + const segIndex = createSegmentIndex(collectStaticSegments(annotations, page, excludeIds)); + const own = selectOwnProbes(ownPoints); + const acquirer = createAcquirer({ delayMs: ACQUIRE_MS, max: MAX_ACQUIRED }); + let timer = null; + let anchor = { x: start.x, y: start.y }; + + function segmentQuery(x, y, r) { + const out = segIndex.near(x, y, r); + if (pdfSegments) { + try { + const pdf = getPdfEdgeSegmentsNear(page, x, y, r); + const lim = Math.min(pdf.length, MAX_PDF_SEGMENTS); + for (let i = 0; i < lim; i++) out.push(pdf[i]); + } catch (_) { /* PDF geometry not loaded */ } + } + return out; + } + + function clearTimer() { + if (timer) { clearTimeout(timer); timer = null; } + } + + return { + page, + setStart(p) { anchor = { x: p.x, y: p.y }; }, + acquired: () => acquirer.list(), + /** Feed the hovered static snap point (or null); schedules acquisition. */ + hover(pt) { + clearTimer(); + const r = acquirer.update(pt, performance.now()); + if (r.pendingMs > 0 && typeof onAcquire === 'function') { + timer = setTimeout(() => { + timer = null; + const r2 = acquirer.update(pt, performance.now()); + if (r2.changed) onAcquire(); + }, r.pendingMs + 5); + } + return r.changed; + }, + /** + * Run the inference for the raw cursor. prefs = state.preferences. + * Returns null or the engine result. + */ + infer(cursor, { prefs, scale, lockLine = null } = {}) { + const sc = scale > 0 ? scale : 1; + const tol = TRACK_TOL_PX / sc; + // The Object snap master switch also governs inference: with it off a + // move is free (no tracking, axis, extension or parallel guides). Polar + // stays independent, like the polar pass of the snap engine. + const objectSnap = prefs.enableObjectSnap !== false; + const enable = { + tracking: objectSnap && prefs.moveInferenceTracking !== false, + extension: objectSnap && prefs.moveInferenceExtension !== false, + parallel: objectSnap && prefs.moveInferenceParallel !== false, + }; + const polar = prefs.polarTrackingEnabled + ? { incrementDeg: prefs.polarIncrement || 45, toleranceDeg: prefs.polarTolerance ?? 3 } + : null; + if (!enable.tracking && !enable.extension && !enable.parallel && !polar && !lockLine) return null; + const dx = cursor.x - anchor.x, dy = cursor.y - anchor.y; + const probes = own.map(op => ({ x: op.x + dx, y: op.y + dy, ox: op.x - anchor.x, oy: op.y - anchor.y, type: op.type })); + return inferMove({ + start: anchor, cursor, probes, pointIndex: enable.tracking ? staticIndex : null, + segmentQuery, acquired: enable.tracking ? acquirer.list() : [], + tol, reach: REACH_PX / sc, angleTolDeg: Math.max(1, Math.min(prefs.polarTolerance ?? 3, 5)), + polar, enable, lockLine, + extensionReach: EXTENSION_REACH_PX / sc, extensionProbes: 5, + }); + }, + dispose() { + clearTimer(); + acquirer.clear(); + clearMoveInferenceOverlay(); + }, + }; +} + +function _guideColor(g, quarterTurn) { + if (g.kind === 'parallel' || g.kind === 'perpendicular') return GUIDE_COLORS.parallel; + const ax = screenAxis(g.line.dx, g.line.dy, quarterTurn); + if (ax) return GUIDE_COLORS[ax]; + if (g.kind === 'extension') return GUIDE_COLORS.extension; + if (g.kind === 'polar') return GUIDE_COLORS.polar; + return GUIDE_COLORS.intersection; +} + +function _guideLabel(g, quarterTurn, angleDeg) { + switch (g.kind) { + case 'axis': { + const ax = screenAxis(g.line.dx, g.line.dy, quarterTurn); + return ax === 'y' ? _t('onAxisY', 'On Y axis') : _t('onAxisX', 'On X axis'); + } + case 'tracking': + case 'acquired': return _t('fromPoint', 'From point'); + case 'extension': return _t('extension', 'On extension'); + case 'parallel': return _t('parallel', 'Parallel'); + case 'perpendicular': return _t('perpendicular', 'Perpendicular'); + case 'polar': return `${_t('polar', 'Polar')} ${angleDeg.toFixed(0)}°`; + case 'locked': return _t('locked', 'Locked'); + default: return ''; + } +} + +/** + * Overlay for an inference result. `delta` is the applied move (result − + * start); own-point guides are drawn at the probe's real position. + */ +export function buildInferenceOverlay(result, { + page, start, acquired = [], quarterTurn = false, scale = 1, showDistance = true, +} = {}) { + const sc = scale > 0 ? scale : 1; + const lines = []; + const markers = []; + for (const a of acquired) markers.push({ x: a.x, y: a.y, kind: 'acquired', color: GUIDE_COLORS.extension }); + let label = null; + if (result) { + const R = { x: result.x, y: result.y }; + const far = 50000; + const over = 30 / sc; + for (const g of result.guides || []) { + const color = _guideColor(g, quarterTurn); + const ox = g.probe ? g.probe.ox : 0, oy = g.probe ? g.probe.oy : 0; + const P = { x: R.x + ox, y: R.y + oy }; // where the probe really is + const throughStart = g.kind === 'axis' || g.kind === 'polar' || g.kind === 'parallel' + || g.kind === 'perpendicular' || g.kind === 'locked'; + if (throughStart) { + lines.push({ x1: g.line.px - g.line.dx * far, y1: g.line.py - g.line.dy * far, + x2: g.line.px + g.line.dx * far, y2: g.line.py + g.line.dy * far, color, width: g.kind === 'locked' ? 1.4 : 0.8 }); + } else if (g.source) { + const vx = P.x - g.source.x, vy = P.y - g.source.y; + const len = Math.hypot(vx, vy) || 1; + lines.push({ x1: g.source.x, y1: g.source.y, x2: P.x + (vx / len) * over, y2: P.y + (vy / len) * over, color }); + markers.push({ x: g.source.x, y: g.source.y, kind: 'source', color }); + } + if (g.segment && (g.kind === 'parallel' || g.kind === 'perpendicular')) { + const s = g.segment; + lines.push({ x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2, color, dash: false, width: 1.6 }); + const ang = Math.atan2(s.y2 - s.y1, s.x2 - s.x1); + markers.push({ x: (s.x1 + s.x2) / 2, y: (s.y1 + s.y2) / 2, kind: g.kind, color, angle: ang }); + } + } + if (result.kind === 'intersection') markers.push({ x: R.x, y: R.y, kind: 'intersection', color: GUIDE_COLORS.intersection }); + const g0 = (result.guides || [])[0]; + let text = ''; + let color = '#333333'; + if (result.kind === 'intersection') { + text = _t('intersection', 'Intersection'); + color = GUIDE_COLORS.intersection; + } else if (g0) { + const ang = ((Math.atan2(-g0.line.dy, g0.line.dx) * 180) / Math.PI + 360) % 360; + text = _guideLabel(g0, quarterTurn, ang); + color = _guideColor(g0, quarterTurn); + } + if (text && showDistance && start) { + const len = Math.hypot(R.x - start.x, R.y - start.y); + try { + // No scale on the page: show plain points, never the 1 pt = 1 mm fallback. + const ms = findMeasureScale(page, start.x, start.y); + if (ms && ms.pixelsPerUnit > 0) text += ` · ${(len / ms.pixelsPerUnit).toFixed(2)} ${ms.unit || ''}`.trimEnd(); + else text += ` · ${len.toFixed(2)} pt`; + } catch (_) { /* no scale */ } + } + if (text) label = { x: R.x, y: R.y, text, color }; + } + return { page, lines, markers, label }; +} + +/** Publish (or clear with null) the overlay for the render passes. */ +export function publishInferenceOverlay(overlay) { + if (overlay && (overlay.lines.length || overlay.markers.length || overlay.label)) setMoveInferenceOverlay(overlay); + else clearMoveInferenceOverlay(); +} + +/** Lock line for an X/Y axis lock through the base point. */ +export function axisLockLine(start, lockAxis) { + if (lockAxis === 'x') return makeLine(start.x, start.y, 1, 0); + if (lockAxis === 'y') return makeLine(start.x, start.y, 0, 1); + return null; +} + +export { createPointIndex }; diff --git a/open-pdf-studio/js/tools/move-inference.test.mjs b/open-pdf-studio/js/tools/move-inference.test.mjs new file mode 100644 index 000000000..b5d92abe0 --- /dev/null +++ b/open-pdf-studio/js/tools/move-inference.test.mjs @@ -0,0 +1,163 @@ +// Verplaatssessie met inferentie (#519): de kandidaten komen uit de echte +// snap-engine (annotatieranden, wandvlakken), de selectie zelf doet nooit mee, +// de voorkeuren schakelen elk soort hulplijn, en de overlay kleurt per as/soort. +// Echte state-store; Solid/TS zoals in de app (zie ../core/app-test-hooks.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; +import { readFileSync } from 'node:fs'; + +register('../core/app-test-hooks.mjs', import.meta.url); +const { installeerBrowserStubs } = await import('../core/app-test-hooks.mjs'); +installeerBrowserStubs(); +globalThis.performance ??= { now: () => Date.now() }; + +const { state } = await import('../core/state.ts'); +const { + collectStaticSegments, selectOwnProbes, createMoveInferenceSession, buildInferenceOverlay, + axisLockLine, GUIDE_COLORS, createPointIndex, +} = await import('./move-inference.js'); + +const PREFS = { + enableObjectSnap: true, snapToEndpoints: true, snapToMidpoints: true, snapToCenters: true, + snapToPdfContent: false, polarTrackingEnabled: false, polarIncrement: 45, polarTolerance: 3, + moveInferenceTracking: true, moveInferenceExtension: true, moveInferenceParallel: true, +}; +const prefs = (over = {}) => ({ ...PREFS, ...over }); + +const lijn = { id: 'l1', type: 'line', page: 1, startX: 0, startY: 0, endX: 100, endY: 0 }; +const wand = { id: 'w1', type: 'wall', page: 1, startX: 0, startY: 300, endX: 200, endY: 300, thickness: 20 }; +const vak = { id: 'b1', type: 'box', page: 1, x: 400, y: 400, width: 50, height: 30 }; +const anderePagina = { id: 'l2', type: 'line', page: 2, startX: 0, startY: 0, endX: 10, endY: 10 }; + +test('statische segmenten: lijnranden en wandVLAKKEN, niet de selectie, niet een andere pagina', () => { + const alle = [lijn, wand, vak, anderePagina]; + const segs = collectStaticSegments(alle, 1, new Set(['b1'])); + assert.ok(segs.some(s => s.annotation === lijn)); + assert.ok(!segs.some(s => s.annotation === vak), 'de bewegende selectie levert geen segmenten'); + assert.ok(!segs.some(s => s.annotation === anderePagina)); + const vlakken = segs.filter(s => s.annotation === wand && s.face); + assert.ok(vlakken.length >= 4, `wandomtrek verwacht, kreeg ${vlakken.length}`); + // Er is een vlak evenwijdig aan de as, NIET op de as zelf (y = 300). + assert.ok(vlakken.some(s => Math.abs(s.y1 - s.y2) < 1e-6 && Math.abs(s.y1 - 300) > 1)); +}); + +test('eigen probes: hoeken/eindpunten eerst, dubbele weg, begrensd', () => { + const pts = [ + { x: 1, y: 1, type: 'midpoint' }, { x: 0, y: 0, type: 'corner' }, { x: 0, y: 0, type: 'endpoint' }, + { x: 5, y: 5, type: 'center' }, ...Array.from({ length: 30 }, (_, i) => ({ x: 10 + i, y: 0, type: 'corner' })), + ]; + const p = selectOwnProbes(pts, 12); + assert.equal(p.length, 12); + assert.equal(p[0].type, 'corner'); + assert.ok(!p.some(q => q.type === 'midpoint')); +}); + +function sessie(annotations, over = {}) { + Object.assign(state.preferences, PREFS); + return createMoveInferenceSession({ + staticIndex: createPointIndex([{ x: 100, y: 0, type: 'endpoint' }, { x: 0, y: 0, type: 'endpoint' }]), + annotations, page: 1, excludeIds: new Set(['b1']), + ownPoints: [{ x: 400, y: 400, type: 'corner' }, { x: 450, y: 430, type: 'corner' }], + start: { x: 420, y: 410 }, pdfSegments: false, ...over, + }); +} + +test('sessie: een hoek van de selectie lijnt verticaal uit op het eindpunt van een lijn', () => { + const s = sessie([lijn, vak]); + // Cursor zo dat de hoek (450,430) → x = 102 komt: dx = cursor.x - 420; hoek.x = 450 + dx. + const cursor = { x: 420 + (102 - 450), y: 600 }; + const r = s.infer(cursor, { prefs: prefs(), scale: 1 }); + assert.equal(r.kind, 'tracking'); + assert.ok(Math.abs((450 + r.x - 420) - 100) < 1e-6, JSON.stringify({ x: r.x })); + s.dispose(); +}); + +test('sessie: voorkeuren schakelen volglijnen, verlengden en evenwijdig uit; pool volgt de poolinstellingen', () => { + const s = sessie([lijn, vak], { ownPoints: [] }); + // Verlengde van de lijn y = 0 voorbij (100,0). + const c = { x: 180, y: 2 }; + assert.equal(s.infer(c, { prefs: prefs({ moveInferenceTracking: false, moveInferenceParallel: false }), scale: 1 }).kind, 'extension'); + assert.equal(s.infer(c, { prefs: prefs({ moveInferenceTracking: false, moveInferenceParallel: false, moveInferenceExtension: false }), scale: 1 }), null); + // Pool rond het basispunt (420,410) op 45°. + const p = { x: 420 + 100 * Math.cos(Math.PI / 4 + 0.02), y: 410 + 100 * Math.sin(Math.PI / 4 + 0.02) }; + const uit = prefs({ moveInferenceTracking: false, moveInferenceParallel: false, moveInferenceExtension: false }); + assert.equal(s.infer(p, { prefs: uit, scale: 1 }), null); + assert.equal(s.infer(p, { prefs: { ...uit, polarTrackingEnabled: true }, scale: 1 }).kind, 'polar'); + s.dispose(); +}); + +test('sessie: X-asvergrendeling als lockLine snijdt met de verticale uitlijning', () => { + const s = sessie([lijn, vak], { ownPoints: [], start: { x: 0, y: 500 } }); + const r = s.infer({ x: 101, y: 520 }, { prefs: prefs(), scale: 1, lockLine: axisLockLine({ x: 0, y: 500 }, 'x') }); + assert.equal(r.kind, 'intersection'); + assert.ok(Math.abs(r.x - 100) < 1e-6 && Math.abs(r.y - 500) < 1e-6); + s.dispose(); +}); + +test('overlay: rood = X, groen = Y, magenta = evenwijdig; een kwartslag gedraaide weergave wisselt de assen', () => { + const hor = { x: 300, y: 0, kind: 'axis', guides: [{ kind: 'axis', line: { px: 0, py: 0, dx: 1, dy: 0 }, source: { x: 0, y: 0 } }] }; + let o = buildInferenceOverlay(hor, { page: 1, start: { x: 0, y: 0 }, scale: 1, showDistance: false }); + assert.equal(o.lines[0].color, GUIDE_COLORS.x); + assert.equal(o.label.text, 'On X axis'); + o = buildInferenceOverlay(hor, { page: 1, start: { x: 0, y: 0 }, scale: 1, quarterTurn: true, showDistance: false }); + assert.equal(o.lines[0].color, GUIDE_COLORS.y); + assert.equal(o.label.text, 'On Y axis'); + const seg = { x1: 0, y1: 0, x2: 100, y2: 50 }; + const par = { x: 10, y: 5, kind: 'parallel', guides: [{ kind: 'parallel', line: { px: 0, py: 0, dx: 0.894, dy: 0.447 }, segment: seg }] }; + o = buildInferenceOverlay(par, { page: 1, start: { x: 0, y: 0 }, scale: 1, showDistance: false }); + assert.ok(o.lines.every(l => l.color === GUIDE_COLORS.parallel)); + assert.ok(o.markers.some(m => m.kind === 'parallel')); + assert.equal(o.label.text, 'Parallel'); + // Verworven punten krijgen altijd een markering, ook zonder inferentie. + o = buildInferenceOverlay(null, { page: 1, acquired: [{ x: 1, y: 2 }] }); + assert.deepEqual(o.markers.map(m => m.kind), ['acquired']); + assert.equal(o.label, null); +}); + +test('g-move-mode gebruikt de inferentie, de prioriteit en ruimt de overlay op', () => { + const bron = readFileSync(new URL('./g-move-mode.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); + assert.match(bron, /createMoveInferenceSession\(/); + assert.match(bron, /resolveSnapPriority\(cands\)/); + assert.match(bron, /cycleTypeLengthField\(\)/); + assert.match(bron, /inference\?\.dispose\(\)/); + // Een getypte afstand volgt de afgeleide richting, niet de rauwe cursor. + assert.match(bron, /typeLengthApply\(start\.x, start\.y, dir\.x, dir\.y\)/); + // De selectie snapt nooit op zichzelf: statische kandidaten zonder de doelen. + assert.match(bron, /collectSnapPoints\(doc\.annotations \|\| \[\], page, _excludeSet\(\)\)/); + assert.match(bron, /excludeIds: _excludeSet\(\)/); +}); + +test('Object snap uit: verplaatsen is vrij, geen volglijnen, as-hulplijnen, verlengden of evenwijdig', () => { + const s = sessie([lijn, vak]); + const uit = prefs({ enableObjectSnap: false }); + // Volglijn op het eindpunt van de lijn (zie de eerste sessietest). + assert.equal(s.infer({ x: 420 + (102 - 450), y: 600 }, { prefs: uit, scale: 1 }), null); + // Horizontale as door het basispunt (420,410). + assert.equal(s.infer({ x: 600, y: 411 }, { prefs: uit, scale: 1 }), null); + s.dispose(); + const s2 = sessie([lijn, vak], { ownPoints: [] }); + // Verlengde van de lijn y = 0 voorbij (100,0): met Object snap aan wel, uit niet. + assert.equal(s2.infer({ x: 180, y: 2 }, { prefs: prefs({ moveInferenceTracking: false, moveInferenceParallel: false }), scale: 1 }).kind, 'extension'); + assert.equal(s2.infer({ x: 180, y: 2 }, { prefs: prefs({ enableObjectSnap: false, moveInferenceTracking: false, moveInferenceParallel: false }), scale: 1 }), null); + // Pool staat los van de hoofdschakelaar, net als in de snap-engine. + const p = { x: 420 + 100 * Math.cos(Math.PI / 4 + 0.02), y: 410 + 100 * Math.sin(Math.PI / 4 + 0.02) }; + assert.equal(s2.infer(p, { prefs: { ...uit, polarTrackingEnabled: true }, scale: 1 }).kind, 'polar'); + s2.dispose(); +}); + +test('tooltip-afstand: zonder schaal in punten (nooit de mm-terugval), met schaal in de schaaleenheid', () => { + const hor = { x: 300, y: 0, kind: 'axis', guides: [{ kind: 'axis', line: { px: 0, py: 0, dx: 1, dy: 0 }, source: { x: 0, y: 0 } }] }; + const vorige = state.preferences.measureScale; + try { + state.preferences.measureScale = null; + let o = buildInferenceOverlay(hor, { page: 1, start: { x: 0, y: 0 }, scale: 1 }); + assert.equal(o.label.text, 'On X axis · 300.00 pt'); + assert.doesNotMatch(o.label.text, /mm/); + state.preferences.measureScale = { pixelsPerUnit: 2, unit: 'mm' }; + o = buildInferenceOverlay(hor, { page: 1, start: { x: 0, y: 0 }, scale: 1 }); + assert.equal(o.label.text, 'On X axis · 150.00 mm'); + } finally { + state.preferences.measureScale = vorige; + } +}); diff --git a/open-pdf-studio/js/tools/snap-engine.js b/open-pdf-studio/js/tools/snap-engine.js index 136189e07..a07b8e5ee 100644 --- a/open-pdf-studio/js/tools/snap-engine.js +++ b/open-pdf-studio/js/tools/snap-engine.js @@ -416,7 +416,7 @@ export function nearestPointOnEdge(cursorX, cursorY, annotations, currentPage, s return bestPoint; } -function getEdgeSegments(ann) { +export function getEdgeSegments(ann) { const segments = []; switch (ann.type) { diff --git a/open-pdf-studio/js/tools/type-length-input.js b/open-pdf-studio/js/tools/type-length-input.js index ea15c51f0..2baab6b49 100644 --- a/open-pdf-studio/js/tools/type-length-input.js +++ b/open-pdf-studio/js/tools/type-length-input.js @@ -47,10 +47,15 @@ export { parseCoordBuffer }; const [_buffer, _setBuffer] = createSignal(''); const [_cursorScreen, _setCursorScreen] = createSignal({ x: 0, y: 0 }); const [_format, _setFormat] = createSignal('empty'); +// Which field of a polar entry ("d<θ") receives the typed characters: +// 'end' = the field at the end of the buffer (default), 'dist' = the distance +// before '<'. Tab (cycleTypeLengthField) switches between them. +const [_focus, _setFocus] = createSignal('end'); export const typeLengthBuffer = _buffer; export const typeLengthCursor = _cursorScreen; export const typeLengthFormat = _format; +export const typeLengthFocus = _focus; // ── Internal mode state ──────────────────────────────────────────────────── // `active` is een signal zodat de HUD-knop ook zichtbaar wordt zodra een @@ -83,6 +88,7 @@ export function enterTypeLengthMode(startX, startY) { _mode.startY = startY; _setBuffer(''); _setFormat('empty'); + _setFocus('end'); } /** Deactivate (called from tool onDeactivate or after final commit). */ @@ -92,12 +98,14 @@ export function exitTypeLengthMode() { _mode.startY = 0; _setBuffer(''); _setFormat('empty'); + _setFocus('end'); } /** Clear only the buffer but keep the mode active. */ export function clearTypeLengthBuffer() { _setBuffer(''); _setFormat('empty'); + _setFocus('end'); } /** Update startX/Y for tools that move along (e.g. polyline next segment). */ @@ -106,6 +114,7 @@ export function setTypeLengthStart(x, y) { _mode.startY = y; _setBuffer(''); _setFormat('empty'); + _setFocus('end'); } /** Called from the canvas pointermove so the HUD can follow the cursor. */ @@ -135,6 +144,12 @@ function _reparse() { export function consumeKey(key) { if (!_active()) return { handled: false }; + // Editing the DISTANCE of a polar entry after Tab moved the focus there. + if (_focus() === 'dist' && _buffer().includes('<')) { + const r = _consumeInDistanceField(key); + if (r) return r; + } + // Allow chars that participate in any of the four formats: digits, '.', ',', // '<', '=', '-' plus the editing keys. if (/^[0-9]$/.test(key)) { @@ -208,11 +223,48 @@ export function consumeKey(key) { if (_buffer().length === 0) return { handled: false }; _setBuffer(''); _setFormat('empty'); + _setFocus('end'); return { handled: true, aborted: true }; } return { handled: false }; } +// Keys typed while the distance field of "d<θ" has the focus. Returns null +// for keys that behave as usual (Enter, Escape, Tab, unrelated keys). +function _consumeInDistanceField(key) { + const buf = _buffer(); + const i = buf.indexOf('<'); + const left = buf.slice(0, i), right = buf.slice(i); + const set = (l) => { _setBuffer(l + right); _reparse(); return { handled: true, committed: false }; }; + if (/^[0-9]$/.test(key) || key === '.') return set(left + key); + if (key === '-') return left.length === 0 ? set('-') : { handled: true, committed: false }; + if (key === 'Backspace') return set(left.slice(0, -1)); + if (key === ',' || key === '<' || key === '=') return { handled: true, committed: false }; + return null; +} + +/** + * Tab while typing: switch between distance and angle. A plain length + * ("100") becomes a polar entry ("100<") with the angle field focused; in a + * polar entry Tab toggles the focus between distance and angle. + * Returns true when the key was used. + */ +export function cycleTypeLengthField() { + if (!_active()) return false; + const r = parseCoordBuffer(_buffer()); + if (r.kind === 'length' && r.a != null) { + _setBuffer(_buffer() + '<'); + _reparse(); + _setFocus('end'); + return true; + } + if (r.kind === 'polar') { + _setFocus(_focus() === 'dist' ? 'end' : 'dist'); + return true; + } + return false; +} + // ── Endpoint constraint ──────────────────────────────────────────────────── /** diff --git a/open-pdf-studio/js/tools/type-length-tab.test.mjs b/open-pdf-studio/js/tools/type-length-tab.test.mjs new file mode 100644 index 000000000..6946711a8 --- /dev/null +++ b/open-pdf-studio/js/tools/type-length-tab.test.mjs @@ -0,0 +1,76 @@ +// Getypte afstand tijdens verplaatsen (#519): een getal beweegt precies die +// afstand langs de afgeleide richting; Tab wisselt tussen afstand en hoek. +// Echte state-store; Solid zoals in de app (zie ../core/app-test-hooks.mjs). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('../core/app-test-hooks.mjs', import.meta.url); +const { installeerBrowserStubs } = await import('../core/app-test-hooks.mjs'); +installeerBrowserStubs(); + +const til = await import('./type-length-input.js'); +const { inferMove, createPointIndex } = await import('./inference-engine.js'); + +const typ = (s) => { for (const k of s) til.consumeKey(k); }; +const pxPerUnit = () => { + // Zelfde schaal als applyToEndpoint gebruikt: 10 eenheden langs +x. + til.enterTypeLengthMode(0, 0); + typ('10'); + const p = til.applyToEndpoint(0, 0, 1, 0); + til.exitTypeLengthMode(); + return p.x / 10; +}; + +test('getypte afstand volgt de afgeleide richting (pool 45°), niet de rauwe cursor', () => { + const k = pxPerUnit(); + const start = { x: 0, y: 0 }; + const rauw = { x: 100 * Math.cos(Math.PI / 4 + 0.03), y: 100 * Math.sin(Math.PI / 4 + 0.03) }; + const r = inferMove({ + start, cursor: rauw, pointIndex: createPointIndex([]), segmentQuery: () => [], + tol: 4, polar: { incrementDeg: 45, toleranceDeg: 3 }, enable: { tracking: false, extension: false, parallel: false }, + }); + til.enterTypeLengthMode(0, 0); + typ('50'); + const p = til.applyToEndpoint(start.x, start.y, r.x, r.y); + assert.ok(p.constrained); + assert.ok(Math.abs(Math.hypot(p.x, p.y) - 50 * k) < 1e-9); + assert.ok(Math.abs(p.x - p.y) < 1e-9, 'precies op de 45°-straal'); + til.exitTypeLengthMode(); +}); + +test('Tab: afstand → hoekveld → terug naar afstand, zonder de waarden te verliezen', () => { + til.enterTypeLengthMode(0, 0); + assert.equal(til.cycleTypeLengthField(), false, 'leeg: niets om te wisselen'); + typ('100'); + assert.equal(til.cycleTypeLengthField(), true); + assert.equal(til.typeLengthBuffer(), '100<'); + assert.equal(til.typeLengthFocus(), 'end'); + typ('30'); + assert.equal(til.typeLengthBuffer(), '100<30'); + assert.equal(til.typeLengthFormat(), 'polar'); + // Terug naar de afstand: cijfers en Backspace bewerken het deel vóór '<'. + assert.equal(til.cycleTypeLengthField(), true); + assert.equal(til.typeLengthFocus(), 'dist'); + til.consumeKey('Backspace'); + typ('5'); + assert.equal(til.typeLengthBuffer(), '105<30'); + // En weer naar de hoek. + til.cycleTypeLengthField(); + assert.equal(til.typeLengthFocus(), 'end'); + typ('5'); + assert.equal(til.typeLengthBuffer(), '105<305'); + // Escape wist alles en zet de focus terug. + til.consumeKey('Escape'); + assert.equal(til.typeLengthBuffer(), ''); + assert.equal(til.typeLengthFocus(), 'end'); + til.exitTypeLengthMode(); +}); + +test('Tab doet niets bij relatieve of absolute invoer', () => { + til.enterTypeLengthMode(0, 0); + typ('10,5'); + assert.equal(til.cycleTypeLengthField(), false); + assert.equal(til.typeLengthBuffer(), '10,5'); + til.exitTypeLengthMode(); +}); diff --git a/open-pdf-studio/js/types/preferences.ts b/open-pdf-studio/js/types/preferences.ts index 46c442d2f..c9323a8a9 100644 --- a/open-pdf-studio/js/types/preferences.ts +++ b/open-pdf-studio/js/types/preferences.ts @@ -40,6 +40,9 @@ export interface Preferences { objectSnapRadius: number; snapToPdfContent: boolean; enableImageAlignSnap: boolean; + moveInferenceTracking: boolean; + moveInferenceExtension: boolean; + moveInferenceParallel: boolean; keepToolActive: boolean; lineContinue: boolean; ribbonCollapsed: boolean; diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index cf87dae22..9b8b6b39a 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -89,6 +89,10 @@ js/annotations/signature-pad.test.mjs js/tools/aanraak-gebaar.test.mjs js/ui/panels/lagen-lijst.test.mjs js/tools/symbool-snappunten.test.mjs +js/tools/inference-engine.test.mjs +js/tools/move-inference.test.mjs +js/tools/move-inference-i18n.test.mjs +js/tools/type-length-tab.test.mjs js/pdf/pagina-draaien.test.mjs js/pdf/weergave-rotatie.test.mjs js/pdf/weergave-draaien.test.mjs From 6600b8eb73b2c34bdba4baf6aa7f51a3d4b23791 Mon Sep 17 00:00:00 2001 From: Maarten Vroegindeweij <30430941+DutchSailor@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:55:05 +0100 Subject: [PATCH 06/13] chore: generic wording in move-mode comments --- open-pdf-studio/js/tools/g-move-mode.js | 2 +- open-pdf-studio/js/tools/manager.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/open-pdf-studio/js/tools/g-move-mode.js b/open-pdf-studio/js/tools/g-move-mode.js index 7abe33d1f..95371002a 100644 --- a/open-pdf-studio/js/tools/g-move-mode.js +++ b/open-pdf-studio/js/tools/g-move-mode.js @@ -664,7 +664,7 @@ export function cancelMove() { * Caller should preventDefault on the keypress when this returns true. */ /** - * options.basePoint: true → AutoCAD-style two-click flow ('mv' chord): + * options.basePoint: true → CAD-style two-click flow ('mv' chord): * click 1 picks the (object-snapped) BASE point, the selection then tracks * the cursor relative to it, click 2 commits — exact point-to-point moves. * Default (G key): Blender-style, selection follows immediately. diff --git a/open-pdf-studio/js/tools/manager.js b/open-pdf-studio/js/tools/manager.js index fbd80e5a7..e1fb681d7 100644 --- a/open-pdf-studio/js/tools/manager.js +++ b/open-pdf-studio/js/tools/manager.js @@ -263,7 +263,7 @@ function setAnnotationCanvasForTextAccess(enabled) { // Some tools call setTool('select') after committing one annotation. With // `keepToolActive=true` (default) the tool stays active so the user can place -// multiple shapes in a row without re-clicking the toolbar. AutoCAD-style. +// multiple shapes in a row without re-clicking the toolbar, as in CAD programs. // Esc returns to select-tool. Tools should call this helper instead of setTool('select') directly. export function maybeRevertToSelect() { if (state.preferences?.keepToolActive !== false) return; From 1ade2d880502ea899f8571eb13fc8b69fd5d4395 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 10:02:13 +0100 Subject: [PATCH 07/13] feat(structural): rebar, steel connection and bolt drawing tools (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Dutch structural drawing tools as parametric symbols, together with a reinforcement category in the quantities engine so drawn bars feed a bending schedule and a weight schedule. - Rebar core (quantities/wapening.js): notation (12Ø10-150), bending shapes 00/11/21/51 with developed length per NEN-EN 1992-1-1 mandrel diameters, mass 0.00617·d² kg/m, bar collection from annotations, automatic bar marks and merging into bending-schedule rows. - New symbols: rebar detail (bending shape), bar in plan, reinforcement mesh in plan, slab with frost edge, end plate at beam, base plate at steel column (with edge-distance check per NEN-EN 1993-1-8 table 3.3). - Existing reinforcement cage, reinforcement bar and bar series now feed the schedule; cage gets beam length, bar marks and steel grade. - Standard schedules "buigstaat" and "wapeningsstaat"; per-field non-summable columns in the engine. - Palette entries, IFC classes, assistant prompt and MCP template docs. - Translations for all 40 locales; design notes in docs/constructie/wapening-en-staalverbindingen.md. - Tests for geometry, notation, schedule aggregation and save/load. Fixes on top during review: bolt holes follow NEN-EN 1090-2 table 11 for normal round holes (M12/M14 d+1, M16-M24 d+2, M27 and up d+3). Anchor holes in the base plate are a separate, documented size (default d+6 for setting tolerance, adjustable per symbol) that also drives the automatic edge distances and the distance check. A bar series now carries its own bar mark and steel grade (properties panel, save and load) and these reach the bending schedule; a bar series without a bar length no longer shows up as a 0 mm / 0 kg schedule line. Regression tests for each. Fixes #312 --- .../wapening-en-staalverbindingen.md | 209 ++++++++ mcp-stdio/tools.json | 2 +- open-pdf-studio/js/assistant-skills.js | 2 +- .../js/i18n/locales/ar/properties.json | 21 +- .../js/i18n/locales/bg/properties.json | 21 +- .../js/i18n/locales/bn/properties.json | 21 +- .../js/i18n/locales/ca/properties.json | 21 +- .../js/i18n/locales/cs/properties.json | 21 +- .../js/i18n/locales/da/properties.json | 21 +- .../js/i18n/locales/de/properties.json | 21 +- .../js/i18n/locales/el/properties.json | 21 +- .../js/i18n/locales/en/properties.json | 21 +- .../js/i18n/locales/es/properties.json | 21 +- .../js/i18n/locales/fa/properties.json | 21 +- .../js/i18n/locales/fi/properties.json | 21 +- .../js/i18n/locales/fr/properties.json | 21 +- .../js/i18n/locales/he/properties.json | 21 +- .../js/i18n/locales/hi/properties.json | 21 +- .../js/i18n/locales/hr/properties.json | 21 +- .../js/i18n/locales/hu/properties.json | 21 +- .../js/i18n/locales/id/properties.json | 21 +- .../js/i18n/locales/it/properties.json | 21 +- .../js/i18n/locales/ja/properties.json | 21 +- .../js/i18n/locales/ko/properties.json | 21 +- .../js/i18n/locales/lo/properties.json | 21 +- .../js/i18n/locales/ms/properties.json | 21 +- .../js/i18n/locales/nb/properties.json | 21 +- .../js/i18n/locales/nl/properties.json | 21 +- .../js/i18n/locales/pl/properties.json | 21 +- .../js/i18n/locales/pt/properties.json | 21 +- .../js/i18n/locales/ro/properties.json | 21 +- .../js/i18n/locales/ru/properties.json | 21 +- .../js/i18n/locales/sk/properties.json | 21 +- .../js/i18n/locales/sr/properties.json | 21 +- .../js/i18n/locales/sv/properties.json | 21 +- .../js/i18n/locales/sw/properties.json | 21 +- .../js/i18n/locales/ta/properties.json | 21 +- .../js/i18n/locales/th/properties.json | 21 +- .../js/i18n/locales/tr/properties.json | 21 +- .../js/i18n/locales/uk/properties.json | 21 +- .../js/i18n/locales/ur/properties.json | 21 +- .../js/i18n/locales/vi/properties.json | 21 +- .../js/i18n/locales/zh/properties.json | 21 +- .../js/pdf/loader/annotation-converter.js | 2 + .../js/pdf/loader/color-extraction.js | 4 + open-pdf-studio/js/pdf/saver.js | 3 + .../js/pdf/wapening-rondgang.test.mjs | 85 +++ open-pdf-studio/js/quantities/categories.js | 26 +- open-pdf-studio/js/quantities/engine.js | 7 +- .../js/quantities/schedule-templates.js | 26 + open-pdf-studio/js/quantities/wapening.js | 352 +++++++++++++ .../js/quantities/wapening.test.mjs | 216 ++++++++ .../properties-panel/StavenreeksSection.jsx | 25 + .../js/solid/data/ifcCategoryMap.js | 7 + .../js/solid/data/nlSymbolLibrary.js | 14 + .../js/solid/stores/propertiesStore.js | 14 + .../js/solid/stores/quantitiesStore.js | 7 +- .../js/solid/stores/schedulesStore.js | 11 +- open-pdf-studio/js/symbols/registry.js | 12 + .../templates/binnen-bbox.testhulp.mjs | 34 ++ open-pdf-studio/js/symbols/templates/bout.js | 3 + .../js/symbols/templates/staaf-plattegrond.js | 190 +++++++ .../templates/staaf-plattegrond.test.mjs | 65 +++ .../js/symbols/templates/staalprofiel.js | 4 + .../js/symbols/templates/staalverbindingen.js | 486 ++++++++++++++++++ .../templates/staalverbindingen.test.mjs | 125 +++++ .../js/symbols/templates/vloer-vorstrand.js | 270 ++++++++++ .../templates/vloer-vorstrand.test.mjs | 72 +++ .../js/symbols/templates/wapening-lijn.js | 10 + .../js/symbols/templates/wapening-tekst.js | 161 ++++++ .../js/symbols/templates/wapeningsdetail.js | 229 +++++++++ .../templates/wapeningsdetail.test.mjs | 87 ++++ .../templates/wapeningskorf-staat.test.mjs | 29 ++ .../js/symbols/templates/wapeningskorf.js | 47 ++ .../js/symbols/templates/wapeningsnet.js | 171 ++++++ .../symbols/templates/wapeningsnet.test.mjs | 55 ++ open-pdf-studio/js/types/annotation.ts | 2 + open-pdf-studio/scripts/unit-testlijst.txt | 8 + open-pdf-studio/src-tauri/src/mcp_server.rs | 2 +- 79 files changed, 3743 insertions(+), 171 deletions(-) create mode 100644 docs/constructie/wapening-en-staalverbindingen.md create mode 100644 open-pdf-studio/js/pdf/wapening-rondgang.test.mjs create mode 100644 open-pdf-studio/js/quantities/wapening.js create mode 100644 open-pdf-studio/js/quantities/wapening.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/binnen-bbox.testhulp.mjs create mode 100644 open-pdf-studio/js/symbols/templates/staaf-plattegrond.js create mode 100644 open-pdf-studio/js/symbols/templates/staaf-plattegrond.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/staalverbindingen.js create mode 100644 open-pdf-studio/js/symbols/templates/staalverbindingen.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/vloer-vorstrand.js create mode 100644 open-pdf-studio/js/symbols/templates/vloer-vorstrand.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/wapening-tekst.js create mode 100644 open-pdf-studio/js/symbols/templates/wapeningsdetail.js create mode 100644 open-pdf-studio/js/symbols/templates/wapeningsdetail.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/wapeningskorf-staat.test.mjs create mode 100644 open-pdf-studio/js/symbols/templates/wapeningsnet.js create mode 100644 open-pdf-studio/js/symbols/templates/wapeningsnet.test.mjs diff --git a/docs/constructie/wapening-en-staalverbindingen.md b/docs/constructie/wapening-en-staalverbindingen.md new file mode 100644 index 000000000..d72f04177 --- /dev/null +++ b/docs/constructie/wapening-en-staalverbindingen.md @@ -0,0 +1,209 @@ +# Constructieve tekengereedschappen: wapening en staalverbindingen (#312) + +Dit document beschrijft de gereedschappen voor wapenings- en staaldetails +uit issue #312, hoe ze samenhangen met de hoeveelhedenstaat en welke +aannames erin zitten. De gereedschappen staan in het symbolenpalet onder +**NL IFC Bouw**. Het zijn parametrische symbolen: je stelt ze in via het +eigenschappenpaneel en de assistent kan ze plaatsen via `app_create_annotation` +met `type: "parametricSymbol"`. + +## Overzicht per onderdeel van het issue + +| Onderdeel | Gereedschap (`symbolId`) | Status vóór #312 | Na #312 | +|---|---|---|---| +| Schedule (staat) | standaardstaten **Buigstaat** (`buigstaat`) en **Wapeningsstaat** (`wapeningsstaat`) | deels: algemene staten, stavenreeks alleen als lengte | nieuwe categorie *Wapening* met staafnummer, Ø, buigvorm, lengte, aantal, totale lengte, gewicht en staalkwaliteit | +| Wapeningsdetails | `wapeningsdetail` | deels: alleen een rechte staaf met label (`wapeningsstaaf`) | buigvormschets 00/11/21/51 met maten, staafnummer en notatie | +| Wapeningsnet | `wapeningsnet` | deels: `netwapening` als lijn met label | net op ware maat in plattegrond, beide richtingen, boven/onder | +| Parametrisch: rechthoekige balk | `wapeningskorf` | bestond (doorsnede) | telt nu mee in de buigstaat (balklengte, staafnummers, staalkwaliteit) | +| Parametrisch: vloer met vorstrand | `vloer-vorstrand` | ontbrak | doorsnede met netten, vorstrandbeugel en langsstaven | +| Stavenreeks | eigen gereedschap `stavenreeks` | bestond | telt nu mee in de buigstaat | +| Staaf in plattegrond | `staaf-plattegrond` | deels: `wapeningsstaaf` zonder verdeling | staaf met verdeellijn, haken, aantal uit de verdeling | +| Kopplaat bij ligger | `kopplaat` | ontbrak | zij- en kopaanzicht op ware maat, met boutpatroon | +| Voetplaat bij staalprofiel | `voetplaat` | ontbrak | boven- en zijaanzicht op ware maat, met ankers | +| Bout in plattegrond/aanzicht | `bout` | bestond (aanzichten bout, doorsteekbout, plattegrond, moer, zeskant) | ongewijzigd; de boutmaten worden nu ook door kopplaat en voetplaat gebruikt | + +## Notatie + +De notatie volgt de gangbare Nederlandse schrijfwijze: + +- `12Ø10-150`: 12 staven Ø10 hart-op-hart 150 mm; +- `4Ø12`: 4 staven Ø12; +- `Ø8-150`: staven Ø8 h.o.h. 150 (zonder aantal, zoals bij netten); +- `lg=2450`: de staaflengte (ontwikkelde lengte) in mm; +- het staafnummer staat in een cirkel; +- het diameterteken wordt als vector getekend (cirkel met steile streep en twee + vlaggetjes), net als bij de stavenreeks en de wapeningskorf. Daardoor is het + teken ook in andere PDF-lezers zichtbaar, onafhankelijk van het lettertype. + +De rekenkern staat in `open-pdf-studio/js/quantities/wapening.js`. De +symbolen en de staat gebruiken dezelfde functies, zodat het getal op de +tekening en het getal in de staat altijd gelijk zijn. + +## Buigvormen en staaflengte + +De vormcodes volgen het nummerprincipe van NEN-EN ISO 3766: het eerste cijfer +is het aantal bochten. Het gereedschap kent de vormen die in de praktijk het +meest voorkomen: + +| Code | Vorm | Maten | +|---|---|---| +| 00 | recht | a | +| 11 | haakstaaf (L, één bocht van 90°) | a, b | +| 21 | U-staaf (twee bochten van 90°) | a (been), b (basis), c (been) | +| 51 | gesloten beugel met twee haken van 135° | a (breedte), b (hoogte) | + +Aannames voor de lengte: + +- Alle maten zijn **buitenmaten**, zoals op een buigstaat. +- De **doorndiameter** volgt NEN-EN 1992-1-1 tabel 8.1N: 4Ø bij Ø ≤ 16 en 7Ø + bij Ø > 16. +- De **aftrek per bocht van 90°** is `2(r + d) − π/2 · (r + d/2)`, met + r = halve doorndiameter en d = staafdiameter (lengte over de hartlijn van de + staaf). +- Een **gesloten beugel** (51) loopt rond de vier zijden, heeft drie hoeken van + 90° en twee haken van 135° in dezelfde hoek. De rechte verlenging van een + haak is standaard 10Ø (instelbaar). Dat is ruimer dan het EC2-minimum van 5Ø + en 50 mm, zoals gebruikelijk bij beugels. +- De staaflengte wordt naar boven afgerond op 5 mm. + +## Gewicht + +Massa per meter = `0,00617 · d²` kg/m (d in mm). Dat is de afgeronde waarde van +π/4 · 7850 kg/m³. Totale lengte = aantal × staaflengte; gewicht = totale lengte × +massa per meter. + +## Staalkwaliteit + +Standaard **B500B** voor staven en beugels en **B500A** voor netten (koud +vervormd, gangbaar bij standaardnetten). B500A, B500B en B500C zijn te kiezen. + +## Buigstaat en wapeningsstaat + +De categorie *Wapening* in de hoeveelheden verzamelt staven uit: + +- stavenreeksen (aantal, diameter, staaflengte; vorm 00). Staafnummer en + staalkwaliteit (standaard B500B) stel je in het eigenschappenpaneel in; een + leeg staafnummer krijgt een automatisch nummer; +- wapeningsstaven (`wapeningsstaaf`: aantal × lengte; vorm 00); +- de nieuwe symbolen en de wapeningskorf, via `rebarItems(params)` op het + template. + +Gelijke staven (zelfde staafnummer, Ø, vorm, lengte en kwaliteit) worden één +regel en hun aantallen tellen op, ook over meerdere pagina's. Een staaf zonder +staafnummer krijgt een automatisch nummer, aansluitend op het hoogste +handmatige nummer. Gelijke staven delen dat nummer. Krijgen twee verschillende +staven hetzelfde nummer, dan wordt die regel als conflict gemarkeerd +(`conflict: true` in `buigstaatRegels`). + +- **Buigstaat** (`buigstaat`): één regel per staafnummer met Ø, vorm, lengte, + aantal, totale lengte, gewicht en staalkwaliteit. Onderaan staan de + totaal-lengte en het totaalgewicht. +- **Wapeningsstaat** (`wapeningsstaat`): gegroepeerd per Ø met de massa per + meter en de subtotalen van aantal, lengte en gewicht. + +De kolommen Ø, staaflengte en massa per meter worden niet opgeteld. De staten +zijn te kiezen in het staten-paneel, als CSV te exporteren en via MCP te +plaatsen (`app_place_schedule` met `templateId: "buigstaat"` of +`"wapeningsstaat"`). `app_get_takeoff` geeft de totalen terug. + +Wat níet in de staat komt: + +- de netten die in het detail *vloer met vorstrand* getekend zijn. Die tel je + met het gereedschap *Wapeningsnet*, anders zouden ze dubbel meetellen; +- `netwapening` (de oudere lijnweergave): die heeft geen aantal; +- een wapeningskorf zonder balklengte (de standaard is 0, zodat bestaande + tekeningen niet ineens in de staat verschijnen); +- een stavenreeks zonder staaflengte (de standaard is 0). Een regel van 0 mm + en 0 kg zou de staat alleen vervuilen. + +## Wapeningsnet + +Het net wordt op ware maat (lengte × breedte) getekend met één diagonaal, +de Nederlandse tekenwijze. Rond het label is de diagonaal onderbroken. Een +bovennet wordt gestreept getekend, een ondernet doorgetrokken. Het label luidt +`Ø8-150/Ø8-150` (lengterichting/breedterichting), met daaronder de netmaat, +de laag en de kwaliteit. + +Staafaantallen: in de lengterichting `breedte / h.o.h. + 1` staven van de +netlengte, in de breedterichting `lengte / h.o.h. + 1` staven van de +netbreedte, maal het aantal netten. Overstekende staafeinden en overlappen +worden niet apart gerekend. + +## Staaf in plattegrond + +Eén representatieve staaf op ware lengte, met een verdeellijn loodrecht erop +over de verdeelbreedte en schuine eindstreepjes. Omgezette einden worden als +kort been getekend. Het aantal is `verdeelbreedte / h.o.h. + 1`, tenzij je een +vast aantal opgeeft. De vorm volgt uit de haken: geen → 00, één → 11, +beide → 21. + +## Vloer met vorstrand + +Doorsnede over de vloerrand op ware maat: de vloer met breeklijn, de verdiepte +rand, het onder- en/of bovennet (lijn met de dwarsstaven als stippen), de +gesloten beugel en de langsstaven (verdeeld over boven en onder), staafnummers +met aanwijslijn, maatvoering en een legenda. Voor de staat geldt een +randlengte (standaard 10 m): + +- langsstaven: lengte = randlengte − 2 × dekking; +- beugels: aantal = (randlengte − 2 × dekking) / h.o.h. + 1, buitenmaat = + vorstranddoorsnede − 2 × dekking. + +De standaarddekking is 40 mm: gestort op een voorbereide ondergrond, conform +NEN-EN 1992-1-1 4.4.1.3(4). Bij storten direct tegen de grond is 75 mm nodig; +pas de waarde dan aan. + +## Kopplaat bij ligger + +- Plaatbreedte = flensbreedte (of opgegeven), plaathoogte = profielhoogte + + 2 × overstek. +- Gatdiameter d0: normale ronde gaten volgens NEN-EN 1090-2 tabel 11, dus + d + 1 mm voor M12 en M14, d + 2 mm voor M16 t/m M24 en d + 3 mm vanaf M27. +- Automatische randafstand e2 = 1,5 · d0 (afgerond op 5 mm). Automatische + e1 = midden van het overstek als daar een boutrij past (overstek ≥ 2,4 · d0), + anders net onder de flens (overstek + tf + 1,5 · d0). +- De boutrijen worden gelijkmatig verdeeld; er zijn twee boutkolommen. +- `kopplaatGeometrie().controle` toetst e1, e2 ≥ 1,2 · d0; p1 ≥ 2,2 · d0 en + p2 ≥ 2,4 · d0 (NEN-EN 1993-1-8 tabel 3.3). Het symbool tekent de verbinding + ook als die toets faalt. Het is een tekenhulp, geen sterkteberekening. +- Zijaanzicht: liggereinde met breeklijn, kopplaat, hoeklassen aan de flenzen, + bouten met moer en hartlijn. Kopaanzicht: plaat, profieldoorsnede en gaten + met hartkruis. + +## Voetplaat bij staalprofiel + +- Ankergaten zijn ruim vanwege de stelruimte bij het stellen van de kolom. + Aanname: d0 = d + 6 mm (M20 → 26 mm). Met de parameter *Gatdiameter anker* + geef je een andere gatmaat op (0 = d + 6). +- Automatische plaatmaat: profiel + 2 × max(3 · d0, 60 mm) in beide richtingen, + afgerond op 10 mm. Automatische ankerrandafstand e = 1,5 · d0. Hier is d0 + de ankergatmaat, ook in de controle van de rand- en tussenafstanden. +- 2 ankers (op de lijfas), 4 (hoeken) of 6 (hoeken + midden). +- Bovenaanzicht: plaat, kolomdoorsnede (massief), hartlijnen, ankergaten met + ring en hartkruis. Zijaanzicht: kolom met breeklijn, voetplaat, ondersabeling + (gestreept), bovenkant fundering met arcering, hoeklassen, ankers met moer en + ring. De ankerlengte in de fundering is gestreept (verborgen). + +## Opslaan en uitwisseling + +Alle symbolen zijn `parametricSymbol`-annotaties. Ze worden opgeslagen als +/Square met `OPS_SymbolId`/`OPS_Params` en een appearance, en komen bij het +heropenen met dezelfde parameters terug. De test +`js/pdf/wapening-rondgang.test.mjs` controleert dat, en ook dat de buigstaat +van het heropende bestand gelijk is. + +IFC-klassen: wapeningsdetails, staaf in plattegrond en vloer met vorstrand → +`IfcReinforcingBar`; wapeningsnet → `IfcReinforcingMesh`; kopplaat en +voetplaat → `IfcPlate`. + +## Bekende beperkingen + +- De buigvormen beperken zich tot 00, 11, 21 en 51. Kropstaven, spiralen en + vormen met schuine bochten ontbreken nog. +- De teksten op de tekening (labels, legenda) zijn Nederlandse vaktermen + (`lg=`, `bgls`, `net`, `onder`/`boven`, `kopplaat`, `voetplaat`). Dat is de + bedoelde tekenwijze. De velden in het eigenschappenpaneel zijn tweetalig + NL/EN, zoals bij de andere parametrische symbolen. De staat-kolommen zijn in + alle talen vertaald. +- Overlappingslengtes, verankeringslengtes en de sterkte van verbindingen + worden niet berekend. diff --git a/mcp-stdio/tools.json b/mcp-stdio/tools.json index d9d59dd32..1f76d1215 100644 --- a/mcp-stdio/tools.json +++ b/mcp-stdio/tools.json @@ -2332,7 +2332,7 @@ "type": "string" }, "templateId": { - "description": "Template for a new schedule: area, length, count, symbol, text, image or full. Default: area.", + "description": "Template for a new schedule: area, length, count, symbol, text, image, full, buigstaat (rebar bending schedule) or wapeningsstaat (rebar weight per diameter). Default: area.", "type": "string" }, "x": { diff --git a/open-pdf-studio/js/assistant-skills.js b/open-pdf-studio/js/assistant-skills.js index cb6a8b2da..121d801e9 100644 --- a/open-pdf-studio/js/assistant-skills.js +++ b/open-pdf-studio/js/assistant-skills.js @@ -107,7 +107,7 @@ export const SKILLS_SYSTEM_PROMPT = '- FIRST make a call with dryRun: true. It works everything out and reports what would be placed without drawing anything. If it is right, repeat the call without dryRun.\n' + '- Then check with app_list_annotations (or app_get_takeoff for the totals) and report the counts. app_undo removes the whole plan in one step.\n' + '- The grid bubbles are COUPLED: app_structural_layout couples all bubble ends of one direction. When the user drags one bubble (the grip at the end), the other bubbles of that direction move by the same amount, so they stay in line. app_get_annotation on a grid line reports `gridAlignment` per end (start/end: group, locked, linkedEnds, canLock). To unlock one end: app_update_annotation with props { alignStart: false } (the start, where the bubble is) or { alignEnd: false }; with true it is coupled again and moves back onto the line of the other bubbles. Loose grid lines that lie in line are coupled with alignStart/alignEnd: true as well. Moving a whole grid line leaves the coupling alone.\n' + - '- Separate structural elements are drawn with app_create_annotation: type "betonbalk" (startX/startY/endX/endY + breedteMm/hoogteMm, tagTonen/tagTekst; it mitres itself at the corners), or type "parametricSymbol" with symbolId "wapeningsstaaf", "netwapening", "wapeningskorf", "wapeningVerdeling" (params aantal/diameter), "beugel" (params diameter/afstand), "oplegging", "puntlast", "q-last", "windverband", "scharnier-verbinding", "paal-aanzicht-type-1", "sondering", "paalpuntniveau", "peilmaat" (params value), "stramien" (params label/orientation) or "overspanningspijl-vloer" (params lengte/tekst). These ids and parameter names are identifiers of the app: pass them exactly as written.\n' + + '- Separate structural elements are drawn with app_create_annotation: type "betonbalk" (startX/startY/endX/endY + breedteMm/hoogteMm, tagTonen/tagTekst; it mitres itself at the corners), or type "parametricSymbol" with symbolId "wapeningsstaaf", "netwapening", "wapeningskorf", "wapeningVerdeling" (params aantal/diameter), "beugel" (params diameter/afstand), "oplegging", "puntlast", "q-last", "windverband", "scharnier-verbinding", "paal-aanzicht-type-1", "sondering", "paalpuntniveau", "peilmaat" (params value), "stramien" (params label/orientation) or "overspanningspijl-vloer" (params lengte/tekst). Reinforcement and steel connection details (#312) are parametricSymbol too: "wapeningsdetail" (bending shape; params staafnummer/aantal/diameter/afstand/vormcode 00|11|21|51/a/b/c/staalkwaliteit), "staaf-plattegrond" (bar in plan; params staafnummer/diameter/afstand/verdeelbreedte/lengte/haken), "wapeningsnet" (mesh in plan; params lengte/breedte/diameterL/afstandL/diameterB/afstandB/laag), "vloer-vorstrand" (slab with frost edge; params vloerdikte/vorstrandBreedte/vorstrandHoogte/randAantal/randDiameter/beugelDiameter/beugelAfstand/lengte), "kopplaat" (end plate; params profiel/aanzicht zij|kop/plaatDikte/boutMaat/boutRijen) and "voetplaat" (base plate; params profiel/aanzicht boven|zij/plaatDikte/ankerMaat/ankerAantal). Their bars feed the schedule templates "buigstaat" (bending schedule) and "wapeningsstaat" (weight per diameter) of app_place_schedule. These ids and parameter names are identifiers of the app: pass them exactly as written.\n' + '- A scale on its own is set with app_set_measure_scale: at 1:100 one real millimeter is 0.0283465 page point (72/25.4 divided by 100).' + CONSTRUCTIEOVERZICHT_PROMPT + PLATTEGROND_PROMPT; diff --git a/open-pdf-studio/js/i18n/locales/ar/properties.json b/open-pdf-studio/js/i18n/locales/ar/properties.json index 9a209d03c..0f9e75b5e 100644 --- a/open-pdf-studio/js/i18n/locales/ar/properties.json +++ b/open-pdf-studio/js/i18n/locales/ar/properties.json @@ -483,7 +483,9 @@ "symbol": "جدول الرموز", "text": "جدول النصوص", "full": "الكميات الكاملة", - "image": "الصور والأختام" + "image": "الصور والأختام", + "bendingSchedule": "جدول ثني القضبان", + "rebarWeight": "جدول التسليح (الوزن لكل Ø)" }, "preview": "معاينة", "exportCsv": "تصدير CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "نص (أصلي)", "symbol": "رمز", "image": "صورة", - "other": "أخرى" + "other": "أخرى", + "reinforcement": "تسليح" }, "field": { "category": "الفئة", @@ -671,7 +674,16 @@ "width": "العرض", "height": "الارتفاع", "location": "الموقع", - "locationSource": "مصدر الموقع" + "locationSource": "مصدر الموقع", + "rebarMark": "رقم القضيب", + "rebarDiameter": "Ø", + "rebarShape": "شكل الثني", + "rebarGrade": "رتبة الفولاذ", + "rebarCount": "عدد القضبان", + "rebarLength": "طول القضيب", + "rebarTotalLength": "الطول الإجمالي", + "rebarMassPerM": "الكتلة لكل متر", + "rebarMass": "الكتلة" }, "type": { "line": "خط", @@ -709,7 +721,8 @@ "stavenreeks": "سلسلة قضبان", "betonbalk": "جسر خرساني", "scheduleTable": "جدول الكميات", - "builtinText": "نص" + "builtinText": "نص", + "rebarPosition": "قضيب تسليح" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/bg/properties.json b/open-pdf-studio/js/i18n/locales/bg/properties.json index e17c9ea30..f0fc27dad 100644 --- a/open-pdf-studio/js/i18n/locales/bg/properties.json +++ b/open-pdf-studio/js/i18n/locales/bg/properties.json @@ -483,7 +483,9 @@ "symbol": "Спецификация на символи", "text": "Спецификация на текстове", "full": "Пълни количества", - "image": "Изображения и печати" + "image": "Изображения и печати", + "bendingSchedule": "Спецификация за огъване", + "rebarWeight": "Спецификация на армировката (маса по Ø)" }, "preview": "Преглед", "exportCsv": "Експортиране в CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Текст (вграден)", "symbol": "Символ", "image": "Изображение", - "other": "Друго" + "other": "Друго", + "reinforcement": "Армировка" }, "field": { "category": "Категория", @@ -671,7 +674,16 @@ "width": "Ширина", "height": "Височина", "location": "Местоположение", - "locationSource": "Източник на местоположението" + "locationSource": "Източник на местоположението", + "rebarMark": "Позиция", + "rebarDiameter": "Ø", + "rebarShape": "Форма на огъване", + "rebarGrade": "Клас на стоманата", + "rebarCount": "Брой пръти", + "rebarLength": "Дължина на пръта", + "rebarTotalLength": "Обща дължина", + "rebarMassPerM": "Маса на метър", + "rebarMass": "Маса" }, "type": { "line": "Линия", @@ -709,7 +721,8 @@ "stavenreeks": "Серия пръти", "betonbalk": "Бетонна греда", "scheduleTable": "Таблица с количества", - "builtinText": "Текст" + "builtinText": "Текст", + "rebarPosition": "Армировъчен прът" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/bn/properties.json b/open-pdf-studio/js/i18n/locales/bn/properties.json index f6dba6f18..3cc63d2f7 100644 --- a/open-pdf-studio/js/i18n/locales/bn/properties.json +++ b/open-pdf-studio/js/i18n/locales/bn/properties.json @@ -483,7 +483,9 @@ "symbol": "প্রতীক শিডিউল", "text": "টেক্সট শিডিউল", "full": "সম্পূর্ণ পরিমাণ", - "image": "ছবি ও স্ট্যাম্প" + "image": "ছবি ও স্ট্যাম্প", + "bendingSchedule": "রড বাঁকানোর শিডিউল", + "rebarWeight": "রডের ওজন শিডিউল (প্রতি Ø)" }, "preview": "প্রাকদর্শন", "exportCsv": "CSV রপ্তানি করুন", @@ -643,7 +645,8 @@ "textBuiltIn": "টেক্সট (মূল)", "symbol": "প্রতীক", "image": "চিত্র", - "other": "অন্যান্য" + "other": "অন্যান্য", + "reinforcement": "রড (রিইনফোর্সমেন্ট)" }, "field": { "category": "বিভাগ", @@ -671,7 +674,16 @@ "width": "প্রস্থ", "height": "উচ্চতা", "location": "অবস্থান", - "locationSource": "অবস্থানের উৎস" + "locationSource": "অবস্থানের উৎস", + "rebarMark": "রড চিহ্ন", + "rebarDiameter": "Ø", + "rebarShape": "বাঁকানোর আকৃতি", + "rebarGrade": "ইস্পাতের গ্রেড", + "rebarCount": "রডের সংখ্যা", + "rebarLength": "রডের দৈর্ঘ্য", + "rebarTotalLength": "মোট দৈর্ঘ্য", + "rebarMassPerM": "প্রতি মিটার ভর", + "rebarMass": "ভর" }, "type": { "line": "রেখা", @@ -709,7 +721,8 @@ "stavenreeks": "রড সিরিজ", "betonbalk": "কংক্রিট বিম", "scheduleTable": "পরিমাণ সারণি", - "builtinText": "পাঠ্য" + "builtinText": "পাঠ্য", + "rebarPosition": "রড" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ca/properties.json b/open-pdf-studio/js/i18n/locales/ca/properties.json index 6eacfca59..68571756c 100644 --- a/open-pdf-studio/js/i18n/locales/ca/properties.json +++ b/open-pdf-studio/js/i18n/locales/ca/properties.json @@ -489,7 +489,9 @@ "symbol": "Llistat de símbols", "text": "Llistat de textos", "full": "Quantitats completes", - "image": "Imatges i segells" + "image": "Imatges i segells", + "bendingSchedule": "Llistat de doblegat", + "rebarWeight": "Resum d'armadures (pes per Ø)" }, "preview": "Previsualització", "exportCsv": "Exporta CSV", @@ -649,7 +651,8 @@ "textBuiltIn": "Text (natiu)", "symbol": "Símbol", "image": "Imatge", - "other": "Altres" + "other": "Altres", + "reinforcement": "Armadura" }, "field": { "category": "Categoria", @@ -677,7 +680,16 @@ "width": "Amplada", "height": "Alçada", "location": "Ubicació", - "locationSource": "Origen de la ubicació" + "locationSource": "Origen de la ubicació", + "rebarMark": "Marca", + "rebarDiameter": "Ø", + "rebarShape": "Forma de doblegat", + "rebarGrade": "Qualitat de l'acer", + "rebarCount": "Nombre de barres", + "rebarLength": "Longitud de barra", + "rebarTotalLength": "Longitud total", + "rebarMassPerM": "Massa per metre", + "rebarMass": "Massa" }, "type": { "line": "Línia", @@ -715,7 +727,8 @@ "stavenreeks": "Sèrie de barres", "betonbalk": "Biga de formigó", "scheduleTable": "Taula de quantitats", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Barra d'armadura" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/cs/properties.json b/open-pdf-studio/js/i18n/locales/cs/properties.json index 9c3053ecc..68d8c4764 100644 --- a/open-pdf-studio/js/i18n/locales/cs/properties.json +++ b/open-pdf-studio/js/i18n/locales/cs/properties.json @@ -483,7 +483,9 @@ "symbol": "Výkaz symbolů", "text": "Výkaz textů", "full": "Úplný výkaz výměr", - "image": "Obrázky a razítka" + "image": "Obrázky a razítka", + "bendingSchedule": "Výpis ohybů", + "rebarWeight": "Výkaz výztuže (hmotnost dle Ø)" }, "preview": "Náhled", "exportCsv": "Exportovat CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (nativní)", "symbol": "Symbol", "image": "Obrázek", - "other": "Ostatní" + "other": "Ostatní", + "reinforcement": "Výztuž" }, "field": { "category": "Kategorie", @@ -671,7 +674,16 @@ "width": "Šířka", "height": "Výška", "location": "Umístění", - "locationSource": "Zdroj umístění" + "locationSource": "Zdroj umístění", + "rebarMark": "Pozice", + "rebarDiameter": "Ø", + "rebarShape": "Tvar ohybu", + "rebarGrade": "Třída oceli", + "rebarCount": "Počet prutů", + "rebarLength": "Délka prutu", + "rebarTotalLength": "Celková délka", + "rebarMassPerM": "Hmotnost na metr", + "rebarMass": "Hmotnost" }, "type": { "line": "Čára", @@ -709,7 +721,8 @@ "stavenreeks": "Řada prutů", "betonbalk": "Betonový trám", "scheduleTable": "Tabulka výměr", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Výztužný prut" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/da/properties.json b/open-pdf-studio/js/i18n/locales/da/properties.json index a6380dfbb..5bbc9d367 100644 --- a/open-pdf-studio/js/i18n/locales/da/properties.json +++ b/open-pdf-studio/js/i18n/locales/da/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbolliste", "text": "Tekstliste", "full": "Fulde mængder", - "image": "Billeder og stempler" + "image": "Billeder og stempler", + "bendingSchedule": "Bukkeliste", + "rebarWeight": "Armeringsliste (vægt pr. Ø)" }, "preview": "Eksempel", "exportCsv": "Eksportér CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Tekst (indbygget)", "symbol": "Symbol", "image": "Billede", - "other": "Andet" + "other": "Andet", + "reinforcement": "Armering" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Bredde", "height": "Højde", "location": "Placering", - "locationSource": "Kilde til placering" + "locationSource": "Kilde til placering", + "rebarMark": "Positionsnummer", + "rebarDiameter": "Ø", + "rebarShape": "Bukkeform", + "rebarGrade": "Stålkvalitet", + "rebarCount": "Antal stænger", + "rebarLength": "Stanglængde", + "rebarTotalLength": "Samlet længde", + "rebarMassPerM": "Masse pr. meter", + "rebarMass": "Vægt" }, "type": { "line": "Linje", @@ -709,7 +721,8 @@ "stavenreeks": "Stangserie", "betonbalk": "Betonbjælke", "scheduleTable": "Mængdetabel", - "builtinText": "Tekst" + "builtinText": "Tekst", + "rebarPosition": "Armeringsjern" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/de/properties.json b/open-pdf-studio/js/i18n/locales/de/properties.json index c20c99407..426225cea 100644 --- a/open-pdf-studio/js/i18n/locales/de/properties.json +++ b/open-pdf-studio/js/i18n/locales/de/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbolliste", "text": "Textliste", "full": "Vollständige Mengen", - "image": "Bilder & Stempel" + "image": "Bilder & Stempel", + "bendingSchedule": "Biegeliste", + "rebarWeight": "Stahlliste (Gewicht je Ø)" }, "preview": "Vorschau", "exportCsv": "CSV exportieren", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (nativ)", "symbol": "Symbol", "image": "Bild", - "other": "Sonstige" + "other": "Sonstige", + "reinforcement": "Bewehrung" }, "field": { "category": "Kategorie", @@ -671,7 +674,16 @@ "width": "Breite", "height": "Höhe", "location": "Standort", - "locationSource": "Standortquelle" + "locationSource": "Standortquelle", + "rebarMark": "Positionsnummer", + "rebarDiameter": "Ø", + "rebarShape": "Biegeform", + "rebarGrade": "Stahlsorte", + "rebarCount": "Anzahl Stäbe", + "rebarLength": "Stablänge", + "rebarTotalLength": "Gesamtlänge", + "rebarMassPerM": "Masse pro Meter", + "rebarMass": "Gewicht" }, "type": { "line": "Linie", @@ -709,7 +721,8 @@ "stavenreeks": "Stabreihe", "betonbalk": "Betonbalken", "scheduleTable": "Mengentabelle", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Bewehrungsstab" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/el/properties.json b/open-pdf-studio/js/i18n/locales/el/properties.json index b721fadb0..ab6c0ef56 100644 --- a/open-pdf-studio/js/i18n/locales/el/properties.json +++ b/open-pdf-studio/js/i18n/locales/el/properties.json @@ -483,7 +483,9 @@ "symbol": "Πίνακας συμβόλων", "text": "Πίνακας κειμένων", "full": "Πλήρεις ποσότητες", - "image": "Εικόνες & σφραγίδες" + "image": "Εικόνες & σφραγίδες", + "bendingSchedule": "Πίνακας κάμψης", + "rebarWeight": "Πίνακας οπλισμού (βάρος ανά Ø)" }, "preview": "Προεπισκόπηση", "exportCsv": "Εξαγωγή CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Κείμενο (εγγενές)", "symbol": "Σύμβολο", "image": "Εικόνα", - "other": "Άλλο" + "other": "Άλλο", + "reinforcement": "Οπλισμός" }, "field": { "category": "Κατηγορία", @@ -671,7 +674,16 @@ "width": "Πλάτος", "height": "Ύψος", "location": "Τοποθεσία", - "locationSource": "Προέλευση τοποθεσίας" + "locationSource": "Προέλευση τοποθεσίας", + "rebarMark": "Αριθμός θέσης", + "rebarDiameter": "Ø", + "rebarShape": "Σχήμα κάμψης", + "rebarGrade": "Ποιότητα χάλυβα", + "rebarCount": "Πλήθος ράβδων", + "rebarLength": "Μήκος ράβδου", + "rebarTotalLength": "Συνολικό μήκος", + "rebarMassPerM": "Μάζα ανά μέτρο", + "rebarMass": "Μάζα" }, "type": { "line": "Γραμμή", @@ -709,7 +721,8 @@ "stavenreeks": "Σειρά ράβδων", "betonbalk": "Δοκός σκυροδέματος", "scheduleTable": "Πίνακας ποσοτήτων", - "builtinText": "Κείμενο" + "builtinText": "Κείμενο", + "rebarPosition": "Ράβδος οπλισμού" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/en/properties.json b/open-pdf-studio/js/i18n/locales/en/properties.json index 6d46e37b5..f4fcbf811 100644 --- a/open-pdf-studio/js/i18n/locales/en/properties.json +++ b/open-pdf-studio/js/i18n/locales/en/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbol schedule", "text": "Text schedule", "full": "Full quantities", - "image": "Images & stamps" + "image": "Images & stamps", + "bendingSchedule": "Bending schedule", + "rebarWeight": "Rebar weight schedule (per Ø)" }, "preview": "Preview", "exportCsv": "Export CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (native)", "symbol": "Symbol", "image": "Image", - "other": "Other" + "other": "Other", + "reinforcement": "Reinforcement" }, "field": { "category": "Category", @@ -671,7 +674,16 @@ "width": "Width", "height": "Height", "location": "Location", - "locationSource": "Location source" + "locationSource": "Location source", + "rebarMark": "Bar mark", + "rebarDiameter": "Ø", + "rebarShape": "Shape code", + "rebarGrade": "Steel grade", + "rebarCount": "Number of bars", + "rebarLength": "Bar length", + "rebarTotalLength": "Total length", + "rebarMassPerM": "Mass per metre", + "rebarMass": "Mass" }, "type": { "line": "Line", @@ -709,7 +721,8 @@ "stavenreeks": "Bar series", "betonbalk": "Concrete beam", "scheduleTable": "Quantities table", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Reinforcement bar" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/es/properties.json b/open-pdf-studio/js/i18n/locales/es/properties.json index 9b1d4d147..6a381bf4a 100644 --- a/open-pdf-studio/js/i18n/locales/es/properties.json +++ b/open-pdf-studio/js/i18n/locales/es/properties.json @@ -483,7 +483,9 @@ "symbol": "Listado de símbolos", "text": "Listado de textos", "full": "Cantidades completas", - "image": "Imágenes y sellos" + "image": "Imágenes y sellos", + "bendingSchedule": "Planilla de doblado", + "rebarWeight": "Resumen de armaduras (peso por Ø)" }, "preview": "Vista previa", "exportCsv": "Exportar CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Texto (nativo)", "symbol": "Símbolo", "image": "Imagen", - "other": "Otros" + "other": "Otros", + "reinforcement": "Armadura" }, "field": { "category": "Categoría", @@ -671,7 +674,16 @@ "width": "Ancho", "height": "Altura", "location": "Ubicación", - "locationSource": "Origen de la ubicación" + "locationSource": "Origen de la ubicación", + "rebarMark": "Marca", + "rebarDiameter": "Ø", + "rebarShape": "Forma de doblado", + "rebarGrade": "Calidad del acero", + "rebarCount": "Número de barras", + "rebarLength": "Longitud de barra", + "rebarTotalLength": "Longitud total", + "rebarMassPerM": "Masa por metro", + "rebarMass": "Masa" }, "type": { "line": "Línea", @@ -709,7 +721,8 @@ "stavenreeks": "Serie de barras", "betonbalk": "Viga de hormigón", "scheduleTable": "Tabla de cantidades", - "builtinText": "Texto" + "builtinText": "Texto", + "rebarPosition": "Barra de armadura" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/fa/properties.json b/open-pdf-studio/js/i18n/locales/fa/properties.json index 5dd65b534..411b761f1 100644 --- a/open-pdf-studio/js/i18n/locales/fa/properties.json +++ b/open-pdf-studio/js/i18n/locales/fa/properties.json @@ -483,7 +483,9 @@ "symbol": "جدول نمادها", "text": "جدول متن‌ها", "full": "مقادیر کامل", - "image": "تصاویر و مهرها" + "image": "تصاویر و مهرها", + "bendingSchedule": "جدول خم میلگرد", + "rebarWeight": "جدول وزن میلگرد (به تفکیک Ø)" }, "preview": "پیش‌نمایش", "exportCsv": "صدور CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "متن (داخلی)", "symbol": "نماد", "image": "تصویر", - "other": "سایر" + "other": "سایر", + "reinforcement": "آرماتور" }, "field": { "category": "دسته", @@ -671,7 +674,16 @@ "width": "عرض", "height": "ارتفاع", "location": "مکان", - "locationSource": "منبع مکان" + "locationSource": "منبع مکان", + "rebarMark": "شماره میلگرد", + "rebarDiameter": "Ø", + "rebarShape": "شکل خم", + "rebarGrade": "رده فولاد", + "rebarCount": "تعداد میلگرد", + "rebarLength": "طول میلگرد", + "rebarTotalLength": "طول کل", + "rebarMassPerM": "وزن در متر", + "rebarMass": "وزن" }, "type": { "line": "خط", @@ -709,7 +721,8 @@ "stavenreeks": "سری میلگرد", "betonbalk": "تیر بتنی", "scheduleTable": "جدول مقادیر", - "builtinText": "متن" + "builtinText": "متن", + "rebarPosition": "میلگرد" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/fi/properties.json b/open-pdf-studio/js/i18n/locales/fi/properties.json index 89432bf53..9fbdd14c5 100644 --- a/open-pdf-studio/js/i18n/locales/fi/properties.json +++ b/open-pdf-studio/js/i18n/locales/fi/properties.json @@ -483,7 +483,9 @@ "symbol": "Symboliluettelo", "text": "Tekstiluettelo", "full": "Kaikki määrät", - "image": "Kuvat ja leimat" + "image": "Kuvat ja leimat", + "bendingSchedule": "Taivutusluettelo", + "rebarWeight": "Raudoitusluettelo (paino Ø:n mukaan)" }, "preview": "Esikatselu", "exportCsv": "Vie CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Teksti (alkuperäinen)", "symbol": "Symboli", "image": "Kuva", - "other": "Muu" + "other": "Muu", + "reinforcement": "Raudoitus" }, "field": { "category": "Luokka", @@ -671,7 +674,16 @@ "width": "Leveys", "height": "Korkeus", "location": "Sijainti", - "locationSource": "Sijainnin lähde" + "locationSource": "Sijainnin lähde", + "rebarMark": "Positio", + "rebarDiameter": "Ø", + "rebarShape": "Taivutusmuoto", + "rebarGrade": "Teräslaji", + "rebarCount": "Tankojen määrä", + "rebarLength": "Tangon pituus", + "rebarTotalLength": "Kokonaispituus", + "rebarMassPerM": "Massa metriä kohti", + "rebarMass": "Massa" }, "type": { "line": "Viiva", @@ -709,7 +721,8 @@ "stavenreeks": "Tankosarja", "betonbalk": "Betonipalkki", "scheduleTable": "Määrätaulukko", - "builtinText": "Teksti" + "builtinText": "Teksti", + "rebarPosition": "Raudoitustanko" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/fr/properties.json b/open-pdf-studio/js/i18n/locales/fr/properties.json index 617b02ef8..2f123bc3d 100644 --- a/open-pdf-studio/js/i18n/locales/fr/properties.json +++ b/open-pdf-studio/js/i18n/locales/fr/properties.json @@ -483,7 +483,9 @@ "symbol": "Nomenclature des symboles", "text": "Nomenclature des textes", "full": "Quantités complètes", - "image": "Images et tampons" + "image": "Images et tampons", + "bendingSchedule": "Nomenclature de façonnage", + "rebarWeight": "Récapitulatif des aciers (poids par Ø)" }, "preview": "Aperçu", "exportCsv": "Exporter en CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Texte (natif)", "symbol": "Symbole", "image": "Image", - "other": "Autre" + "other": "Autre", + "reinforcement": "Armatures" }, "field": { "category": "Catégorie", @@ -671,7 +674,16 @@ "width": "Largeur", "height": "Hauteur", "location": "Emplacement", - "locationSource": "Source de l'emplacement" + "locationSource": "Source de l'emplacement", + "rebarMark": "Repère", + "rebarDiameter": "Ø", + "rebarShape": "Code de façonnage", + "rebarGrade": "Nuance d'acier", + "rebarCount": "Nombre de barres", + "rebarLength": "Longueur de barre", + "rebarTotalLength": "Longueur totale", + "rebarMassPerM": "Masse par mètre", + "rebarMass": "Masse" }, "type": { "line": "Ligne", @@ -709,7 +721,8 @@ "stavenreeks": "Série de barres", "betonbalk": "Poutre en béton", "scheduleTable": "Tableau des quantités", - "builtinText": "Texte" + "builtinText": "Texte", + "rebarPosition": "Barre d'armature" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/he/properties.json b/open-pdf-studio/js/i18n/locales/he/properties.json index f454039e7..5337c905b 100644 --- a/open-pdf-studio/js/i18n/locales/he/properties.json +++ b/open-pdf-studio/js/i18n/locales/he/properties.json @@ -483,7 +483,9 @@ "symbol": "טבלת סמלים", "text": "טבלת טקסטים", "full": "כמויות מלאות", - "image": "תמונות וחותמות" + "image": "תמונות וחותמות", + "bendingSchedule": "רשימת כיפוף", + "rebarWeight": "ריכוז ברזל (משקל לפי Ø)" }, "preview": "תצוגה מקדימה", "exportCsv": "ייצוא CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "טקסט (מקורי)", "symbol": "סמל", "image": "תמונה", - "other": "אחר" + "other": "אחר", + "reinforcement": "זיון" }, "field": { "category": "קטגוריה", @@ -671,7 +674,16 @@ "width": "רוחב", "height": "גובה", "location": "מיקום", - "locationSource": "מקור המיקום" + "locationSource": "מקור המיקום", + "rebarMark": "מספר מוט", + "rebarDiameter": "Ø", + "rebarShape": "צורת כיפוף", + "rebarGrade": "סוג פלדה", + "rebarCount": "מספר מוטות", + "rebarLength": "אורך מוט", + "rebarTotalLength": "אורך כולל", + "rebarMassPerM": "מסה למטר", + "rebarMass": "מסה" }, "type": { "line": "קו", @@ -709,7 +721,8 @@ "stavenreeks": "סדרת מוטות", "betonbalk": "קורת בטון", "scheduleTable": "טבלת כמויות", - "builtinText": "טקסט" + "builtinText": "טקסט", + "rebarPosition": "מוט זיון" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/hi/properties.json b/open-pdf-studio/js/i18n/locales/hi/properties.json index 39c84225e..42e7640d9 100644 --- a/open-pdf-studio/js/i18n/locales/hi/properties.json +++ b/open-pdf-studio/js/i18n/locales/hi/properties.json @@ -483,7 +483,9 @@ "symbol": "प्रतीक शेड्यूल", "text": "टेक्स्ट शेड्यूल", "full": "पूर्ण मात्राएं", - "image": "छवियां और स्टैम्प" + "image": "छवियां और स्टैम्प", + "bendingSchedule": "सरिया मोड़ शेड्यूल", + "rebarWeight": "सरिया भार शेड्यूल (प्रति Ø)" }, "preview": "पूर्वावलोकन", "exportCsv": "CSV निर्यात करें", @@ -643,7 +645,8 @@ "textBuiltIn": "टेक्स्ट (मूल)", "symbol": "प्रतीक", "image": "छवि", - "other": "अन्य" + "other": "अन्य", + "reinforcement": "सरिया (सुदृढ़ीकरण)" }, "field": { "category": "श्रेणी", @@ -671,7 +674,16 @@ "width": "चौड़ाई", "height": "ऊंचाई", "location": "स्थान", - "locationSource": "स्थान स्रोत" + "locationSource": "स्थान स्रोत", + "rebarMark": "सरिया चिह्न", + "rebarDiameter": "Ø", + "rebarShape": "मोड़ आकृति", + "rebarGrade": "इस्पात ग्रेड", + "rebarCount": "सरियों की संख्या", + "rebarLength": "सरिये की लंबाई", + "rebarTotalLength": "कुल लंबाई", + "rebarMassPerM": "प्रति मीटर द्रव्यमान", + "rebarMass": "द्रव्यमान" }, "type": { "line": "रेखा", @@ -709,7 +721,8 @@ "stavenreeks": "सरिया श्रृंखला", "betonbalk": "कंक्रीट बीम", "scheduleTable": "मात्रा तालिका", - "builtinText": "टेक्स्ट" + "builtinText": "टेक्स्ट", + "rebarPosition": "सरिया" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/hr/properties.json b/open-pdf-studio/js/i18n/locales/hr/properties.json index 3d00ac27f..93e512d4f 100644 --- a/open-pdf-studio/js/i18n/locales/hr/properties.json +++ b/open-pdf-studio/js/i18n/locales/hr/properties.json @@ -483,7 +483,9 @@ "symbol": "Iskaz simbola", "text": "Iskaz tekstova", "full": "Potpune količine", - "image": "Slike i pečati" + "image": "Slike i pečati", + "bendingSchedule": "Iskaz savijanja", + "rebarWeight": "Iskaz armature (masa po Ø)" }, "preview": "Pretpregled", "exportCsv": "Izvezi CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Tekst (izvorni)", "symbol": "Simbol", "image": "Slika", - "other": "Ostalo" + "other": "Ostalo", + "reinforcement": "Armatura" }, "field": { "category": "Kategorija", @@ -671,7 +674,16 @@ "width": "Širina", "height": "Visina", "location": "Lokacija", - "locationSource": "Izvor lokacije" + "locationSource": "Izvor lokacije", + "rebarMark": "Pozicija", + "rebarDiameter": "Ø", + "rebarShape": "Oblik savijanja", + "rebarGrade": "Kvaliteta čelika", + "rebarCount": "Broj šipki", + "rebarLength": "Duljina šipke", + "rebarTotalLength": "Ukupna duljina", + "rebarMassPerM": "Masa po metru", + "rebarMass": "Masa" }, "type": { "line": "Linija", @@ -709,7 +721,8 @@ "stavenreeks": "Niz šipki", "betonbalk": "Betonska greda", "scheduleTable": "Tablica količina", - "builtinText": "Tekst" + "builtinText": "Tekst", + "rebarPosition": "Armaturna šipka" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/hu/properties.json b/open-pdf-studio/js/i18n/locales/hu/properties.json index 65b8bbd09..305c913c3 100644 --- a/open-pdf-studio/js/i18n/locales/hu/properties.json +++ b/open-pdf-studio/js/i18n/locales/hu/properties.json @@ -483,7 +483,9 @@ "symbol": "Szimbólumkimutatás", "text": "Szövegkimutatás", "full": "Teljes mennyiségek", - "image": "Képek és bélyegzők" + "image": "Képek és bélyegzők", + "bendingSchedule": "Hajlítási kimutatás", + "rebarWeight": "Vasalási kimutatás (tömeg Ø szerint)" }, "preview": "Előnézet", "exportCsv": "CSV exportálása", @@ -643,7 +645,8 @@ "textBuiltIn": "Szöveg (natív)", "symbol": "Szimbólum", "image": "Kép", - "other": "Egyéb" + "other": "Egyéb", + "reinforcement": "Vasalás" }, "field": { "category": "Kategória", @@ -671,7 +674,16 @@ "width": "Szélesség", "height": "Magasság", "location": "Hely", - "locationSource": "Hely forrása" + "locationSource": "Hely forrása", + "rebarMark": "Tételszám", + "rebarDiameter": "Ø", + "rebarShape": "Hajlítási forma", + "rebarGrade": "Acélminőség", + "rebarCount": "Rudak száma", + "rebarLength": "Rúdhossz", + "rebarTotalLength": "Teljes hossz", + "rebarMassPerM": "Folyóméter-tömeg", + "rebarMass": "Tömeg" }, "type": { "line": "Vonal", @@ -709,7 +721,8 @@ "stavenreeks": "Rúdsorozat", "betonbalk": "Betongerenda", "scheduleTable": "Mennyiségtáblázat", - "builtinText": "Szöveg" + "builtinText": "Szöveg", + "rebarPosition": "Betonacél rúd" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/id/properties.json b/open-pdf-studio/js/i18n/locales/id/properties.json index 69d406004..1a78f28f7 100644 --- a/open-pdf-studio/js/i18n/locales/id/properties.json +++ b/open-pdf-studio/js/i18n/locales/id/properties.json @@ -483,7 +483,9 @@ "symbol": "Daftar simbol", "text": "Daftar teks", "full": "Kuantitas lengkap", - "image": "Gambar & stempel" + "image": "Gambar & stempel", + "bendingSchedule": "Daftar pembengkokan tulangan", + "rebarWeight": "Rekap berat tulangan (per Ø)" }, "preview": "Pratinjau", "exportCsv": "Ekspor CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Teks (bawaan)", "symbol": "Simbol", "image": "Gambar", - "other": "Lainnya" + "other": "Lainnya", + "reinforcement": "Tulangan" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Lebar", "height": "Tinggi", "location": "Lokasi", - "locationSource": "Sumber lokasi" + "locationSource": "Sumber lokasi", + "rebarMark": "Nomor tulangan", + "rebarDiameter": "Ø", + "rebarShape": "Bentuk bengkokan", + "rebarGrade": "Mutu baja", + "rebarCount": "Jumlah batang", + "rebarLength": "Panjang tulangan", + "rebarTotalLength": "Panjang total", + "rebarMassPerM": "Berat per meter", + "rebarMass": "Berat" }, "type": { "line": "Garis", @@ -709,7 +721,8 @@ "stavenreeks": "Deret tulangan", "betonbalk": "Balok beton", "scheduleTable": "Tabel kuantitas", - "builtinText": "Teks" + "builtinText": "Teks", + "rebarPosition": "Batang tulangan" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/it/properties.json b/open-pdf-studio/js/i18n/locales/it/properties.json index f4caa324e..4f83cc169 100644 --- a/open-pdf-studio/js/i18n/locales/it/properties.json +++ b/open-pdf-studio/js/i18n/locales/it/properties.json @@ -483,7 +483,9 @@ "symbol": "Abaco dei simboli", "text": "Abaco dei testi", "full": "Quantità complete", - "image": "Immagini e timbri" + "image": "Immagini e timbri", + "bendingSchedule": "Distinta di piegatura", + "rebarWeight": "Riepilogo armature (peso per Ø)" }, "preview": "Anteprima", "exportCsv": "Esporta CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Testo (nativo)", "symbol": "Simbolo", "image": "Immagine", - "other": "Altro" + "other": "Altro", + "reinforcement": "Armatura" }, "field": { "category": "Categoria", @@ -671,7 +674,16 @@ "width": "Larghezza", "height": "Altezza", "location": "Ubicazione", - "locationSource": "Origine dell'ubicazione" + "locationSource": "Origine dell'ubicazione", + "rebarMark": "Posizione", + "rebarDiameter": "Ø", + "rebarShape": "Forma di piegatura", + "rebarGrade": "Qualità dell'acciaio", + "rebarCount": "Numero di barre", + "rebarLength": "Lunghezza barra", + "rebarTotalLength": "Lunghezza totale", + "rebarMassPerM": "Massa al metro", + "rebarMass": "Massa" }, "type": { "line": "Linea", @@ -709,7 +721,8 @@ "stavenreeks": "Serie di barre", "betonbalk": "Trave in calcestruzzo", "scheduleTable": "Tabella delle quantità", - "builtinText": "Testo" + "builtinText": "Testo", + "rebarPosition": "Barra d'armatura" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ja/properties.json b/open-pdf-studio/js/i18n/locales/ja/properties.json index 80d77b903..6e6f30ccb 100644 --- a/open-pdf-studio/js/i18n/locales/ja/properties.json +++ b/open-pdf-studio/js/i18n/locales/ja/properties.json @@ -483,7 +483,9 @@ "symbol": "シンボル集計表", "text": "テキスト集計表", "full": "全数量", - "image": "画像とスタンプ" + "image": "画像とスタンプ", + "bendingSchedule": "鉄筋加工表", + "rebarWeight": "鉄筋重量集計表(径別)" }, "preview": "プレビュー", "exportCsv": "CSV をエクスポート", @@ -643,7 +645,8 @@ "textBuiltIn": "テキスト(ネイティブ)", "symbol": "シンボル", "image": "画像", - "other": "その他" + "other": "その他", + "reinforcement": "鉄筋" }, "field": { "category": "カテゴリ", @@ -671,7 +674,16 @@ "width": "幅", "height": "高さ", "location": "場所", - "locationSource": "場所の取得元" + "locationSource": "場所の取得元", + "rebarMark": "鉄筋番号", + "rebarDiameter": "Ø", + "rebarShape": "加工形状", + "rebarGrade": "鋼種", + "rebarCount": "本数", + "rebarLength": "鉄筋長さ", + "rebarTotalLength": "総延長", + "rebarMassPerM": "単位質量", + "rebarMass": "質量" }, "type": { "line": "線", @@ -709,7 +721,8 @@ "stavenreeks": "鉄筋列", "betonbalk": "コンクリート梁", "scheduleTable": "数量表", - "builtinText": "テキスト" + "builtinText": "テキスト", + "rebarPosition": "鉄筋材" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ko/properties.json b/open-pdf-studio/js/i18n/locales/ko/properties.json index 1ebc6bee3..c6b063bf0 100644 --- a/open-pdf-studio/js/i18n/locales/ko/properties.json +++ b/open-pdf-studio/js/i18n/locales/ko/properties.json @@ -483,7 +483,9 @@ "symbol": "기호 일람표", "text": "텍스트 일람표", "full": "전체 물량", - "image": "이미지 및 스탬프" + "image": "이미지 및 스탬프", + "bendingSchedule": "철근 가공표", + "rebarWeight": "철근 중량 집계표 (직경별)" }, "preview": "미리 보기", "exportCsv": "CSV 내보내기", @@ -643,7 +645,8 @@ "textBuiltIn": "텍스트(기본)", "symbol": "기호", "image": "이미지", - "other": "기타" + "other": "기타", + "reinforcement": "철근" }, "field": { "category": "범주", @@ -671,7 +674,16 @@ "width": "너비", "height": "높이", "location": "위치", - "locationSource": "위치 출처" + "locationSource": "위치 출처", + "rebarMark": "철근 번호", + "rebarDiameter": "Ø", + "rebarShape": "가공 형상", + "rebarGrade": "강종", + "rebarCount": "본수", + "rebarLength": "철근 길이", + "rebarTotalLength": "총길이", + "rebarMassPerM": "단위 질량", + "rebarMass": "질량" }, "type": { "line": "선", @@ -709,7 +721,8 @@ "stavenreeks": "철근 배열", "betonbalk": "콘크리트 보", "scheduleTable": "물량표", - "builtinText": "텍스트" + "builtinText": "텍스트", + "rebarPosition": "철근 바" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/lo/properties.json b/open-pdf-studio/js/i18n/locales/lo/properties.json index ddeadc766..c7a24b48b 100644 --- a/open-pdf-studio/js/i18n/locales/lo/properties.json +++ b/open-pdf-studio/js/i18n/locales/lo/properties.json @@ -483,7 +483,9 @@ "symbol": "ຕາຕະລາງສັນຍະລັກ", "text": "ຕາຕະລາງຂໍ້ຄວາມ", "full": "ປະລິມານລວມທັງໝົດ", - "image": "ຮູບພາບ ແລະ ກາປະທັບ" + "image": "ຮູບພາບ ແລະ ກາປະທັບ", + "bendingSchedule": "ຕາຕະລາງການງໍເຫຼັກ", + "rebarWeight": "ຕາຕະລາງນ້ຳໜັກເຫຼັກເສີມ (ຕາມ Ø)" }, "preview": "ສະແດງຕົວຢ່າງ", "exportCsv": "ສົ່ງອອກ CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "ຂໍ້ຄວາມ (ດັ້ງເດີມ)", "symbol": "ສັນຍະລັກ", "image": "ຮູບພາບ", - "other": "ອື່ນໆ" + "other": "ອື່ນໆ", + "reinforcement": "ເຫຼັກເສີມ" }, "field": { "category": "ໝວດໝູ່", @@ -671,7 +674,16 @@ "width": "ລວງກວ້າງ", "height": "ລວງສູງ", "location": "ສະຖານທີ່", - "locationSource": "ແຫຼ່ງທີ່ມາຂອງສະຖານທີ່" + "locationSource": "ແຫຼ່ງທີ່ມາຂອງສະຖານທີ່", + "rebarMark": "ເລກເຫຼັກ", + "rebarDiameter": "Ø", + "rebarShape": "ຮູບແບບການງໍ", + "rebarGrade": "ຊັ້ນຄຸນນະພາບເຫຼັກ", + "rebarCount": "ຈຳນວນເສັ້ນ", + "rebarLength": "ລວງຍາວເຫຼັກ", + "rebarTotalLength": "ລວງຍາວລວມ", + "rebarMassPerM": "ນ້ຳໜັກຕໍ່ແມັດ", + "rebarMass": "ນ້ຳໜັກ" }, "type": { "line": "ເສັ້ນຊື່", @@ -709,7 +721,8 @@ "stavenreeks": "ຊຸດເຫຼັກເສັ້ນ", "betonbalk": "ຄານເບຕົງ", "scheduleTable": "ຕາຕະລາງປະລິມານວັດສະດຸ", - "builtinText": "ຂໍ້ຄວາມ" + "builtinText": "ຂໍ້ຄວາມ", + "rebarPosition": "ທ່ອນເຫຼັກເສີມ" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ms/properties.json b/open-pdf-studio/js/i18n/locales/ms/properties.json index 808ab236d..22ee25542 100644 --- a/open-pdf-studio/js/i18n/locales/ms/properties.json +++ b/open-pdf-studio/js/i18n/locales/ms/properties.json @@ -483,7 +483,9 @@ "symbol": "Jadual simbol", "text": "Jadual teks", "full": "Kuantiti penuh", - "image": "Imej & setem" + "image": "Imej & setem", + "bendingSchedule": "Jadual lenturan bar", + "rebarWeight": "Jadual berat tetulang (mengikut Ø)" }, "preview": "Pratonton", "exportCsv": "Eksport CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Teks (asli)", "symbol": "Simbol", "image": "Imej", - "other": "Lain-lain" + "other": "Lain-lain", + "reinforcement": "Tetulang" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Lebar", "height": "Tinggi", "location": "Lokasi", - "locationSource": "Sumber lokasi" + "locationSource": "Sumber lokasi", + "rebarMark": "Tanda bar", + "rebarDiameter": "Ø", + "rebarShape": "Bentuk lenturan", + "rebarGrade": "Gred keluli", + "rebarCount": "Bilangan bar", + "rebarLength": "Panjang bar", + "rebarTotalLength": "Jumlah panjang", + "rebarMassPerM": "Jisim per meter", + "rebarMass": "Jisim" }, "type": { "line": "Garisan", @@ -709,7 +721,8 @@ "stavenreeks": "Siri bar tetulang", "betonbalk": "Rasuk konkrit", "scheduleTable": "Jadual kuantiti", - "builtinText": "Teks" + "builtinText": "Teks", + "rebarPosition": "Bar tetulang" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/nb/properties.json b/open-pdf-studio/js/i18n/locales/nb/properties.json index dd19591bf..49c726dd2 100644 --- a/open-pdf-studio/js/i18n/locales/nb/properties.json +++ b/open-pdf-studio/js/i18n/locales/nb/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbolliste", "text": "Tekstliste", "full": "Fullstendige mengder", - "image": "Bilder og stempler" + "image": "Bilder og stempler", + "bendingSchedule": "Bøyeliste", + "rebarWeight": "Armeringsliste (vekt per Ø)" }, "preview": "Forhåndsvisning", "exportCsv": "Eksporter CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Tekst (innebygd)", "symbol": "Symbol", "image": "Bilde", - "other": "Annet" + "other": "Annet", + "reinforcement": "Armering" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Bredde", "height": "Høyde", "location": "Plassering", - "locationSource": "Kilde for plassering" + "locationSource": "Kilde for plassering", + "rebarMark": "Posisjonsnummer", + "rebarDiameter": "Ø", + "rebarShape": "Bøyeform", + "rebarGrade": "Stålkvalitet", + "rebarCount": "Antall stenger", + "rebarLength": "Stanglengde", + "rebarTotalLength": "Samlet lengde", + "rebarMassPerM": "Masse per meter", + "rebarMass": "Vekt" }, "type": { "line": "Linje", @@ -709,7 +721,8 @@ "stavenreeks": "Stangserie", "betonbalk": "Betongbjelke", "scheduleTable": "Mengdetabell", - "builtinText": "Tekst" + "builtinText": "Tekst", + "rebarPosition": "Armeringsjern" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/nl/properties.json b/open-pdf-studio/js/i18n/locales/nl/properties.json index ad8d3d807..ea4e7ac7b 100644 --- a/open-pdf-studio/js/i18n/locales/nl/properties.json +++ b/open-pdf-studio/js/i18n/locales/nl/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbolen-staat", "text": "Tekst-staat", "full": "Volledige hoeveelheden", - "image": "Afbeeldingen & stempels" + "image": "Afbeeldingen & stempels", + "bendingSchedule": "Buigstaat", + "rebarWeight": "Wapeningsstaat (gewicht per Ø)" }, "preview": "Voorbeeld", "exportCsv": "CSV exporteren", @@ -643,7 +645,8 @@ "textBuiltIn": "Tekst (native)", "symbol": "Symbool", "image": "Afbeelding", - "other": "Overig" + "other": "Overig", + "reinforcement": "Wapening" }, "field": { "category": "Categorie", @@ -671,7 +674,16 @@ "width": "Breedte", "height": "Hoogte", "location": "Locatie", - "locationSource": "Locatiebron" + "locationSource": "Locatiebron", + "rebarMark": "Staafnummer", + "rebarDiameter": "Ø", + "rebarShape": "Buigvorm", + "rebarGrade": "Staalkwaliteit", + "rebarCount": "Aantal staven", + "rebarLength": "Staaflengte", + "rebarTotalLength": "Totale lengte", + "rebarMassPerM": "Massa per meter", + "rebarMass": "Gewicht" }, "type": { "line": "Lijn", @@ -709,7 +721,8 @@ "stavenreeks": "Stavenreeks", "betonbalk": "Betonbalk", "scheduleTable": "Hoeveelheden-tabel", - "builtinText": "Tekst" + "builtinText": "Tekst", + "rebarPosition": "Wapeningsstaaf" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/pl/properties.json b/open-pdf-studio/js/i18n/locales/pl/properties.json index 3057fc212..fbf881b2d 100644 --- a/open-pdf-studio/js/i18n/locales/pl/properties.json +++ b/open-pdf-studio/js/i18n/locales/pl/properties.json @@ -483,7 +483,9 @@ "symbol": "Zestawienie symboli", "text": "Zestawienie tekstów", "full": "Pełne ilości", - "image": "Obrazy i pieczątki" + "image": "Obrazy i pieczątki", + "bendingSchedule": "Wykaz gięcia", + "rebarWeight": "Zestawienie stali (masa wg Ø)" }, "preview": "Podgląd", "exportCsv": "Eksportuj CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Tekst (natywny)", "symbol": "Symbol", "image": "Obraz", - "other": "Inne" + "other": "Inne", + "reinforcement": "Zbrojenie" }, "field": { "category": "Kategoria", @@ -671,7 +674,16 @@ "width": "Szerokość", "height": "Wysokość", "location": "Lokalizacja", - "locationSource": "Źródło lokalizacji" + "locationSource": "Źródło lokalizacji", + "rebarMark": "Numer pręta", + "rebarDiameter": "Ø", + "rebarShape": "Kształt gięcia", + "rebarGrade": "Gatunek stali", + "rebarCount": "Liczba prętów", + "rebarLength": "Długość pręta", + "rebarTotalLength": "Długość całkowita", + "rebarMassPerM": "Masa na metr", + "rebarMass": "Masa" }, "type": { "line": "Linia", @@ -709,7 +721,8 @@ "stavenreeks": "Seria prętów", "betonbalk": "Belka betonowa", "scheduleTable": "Tabela ilości", - "builtinText": "Tekst" + "builtinText": "Tekst", + "rebarPosition": "Pręt zbrojeniowy" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/pt/properties.json b/open-pdf-studio/js/i18n/locales/pt/properties.json index a2c5ab837..96e81a3db 100644 --- a/open-pdf-studio/js/i18n/locales/pt/properties.json +++ b/open-pdf-studio/js/i18n/locales/pt/properties.json @@ -483,7 +483,9 @@ "symbol": "Quadro de símbolos", "text": "Quadro de textos", "full": "Quantitativos completos", - "image": "Imagens e carimbos" + "image": "Imagens e carimbos", + "bendingSchedule": "Mapa de dobragem", + "rebarWeight": "Resumo de armaduras (peso por Ø)" }, "preview": "Pré-visualização", "exportCsv": "Exportar CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Texto (nativo)", "symbol": "Símbolo", "image": "Imagem", - "other": "Outros" + "other": "Outros", + "reinforcement": "Armadura" }, "field": { "category": "Categoria", @@ -671,7 +674,16 @@ "width": "Largura", "height": "Altura", "location": "Localização", - "locationSource": "Origem da localização" + "locationSource": "Origem da localização", + "rebarMark": "Marca", + "rebarDiameter": "Ø", + "rebarShape": "Forma de dobragem", + "rebarGrade": "Classe do aço", + "rebarCount": "Número de barras", + "rebarLength": "Comprimento da barra", + "rebarTotalLength": "Comprimento total", + "rebarMassPerM": "Massa por metro", + "rebarMass": "Massa" }, "type": { "line": "Linha", @@ -709,7 +721,8 @@ "stavenreeks": "Série de barras", "betonbalk": "Viga de concreto", "scheduleTable": "Tabela de quantitativos", - "builtinText": "Texto" + "builtinText": "Texto", + "rebarPosition": "Barra de armadura" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ro/properties.json b/open-pdf-studio/js/i18n/locales/ro/properties.json index 27e680b5d..d07deab24 100644 --- a/open-pdf-studio/js/i18n/locales/ro/properties.json +++ b/open-pdf-studio/js/i18n/locales/ro/properties.json @@ -483,7 +483,9 @@ "symbol": "Extras de simboluri", "text": "Extras de texte", "full": "Cantități complete", - "image": "Imagini și ștampile" + "image": "Imagini și ștampile", + "bendingSchedule": "Extras de fasonare", + "rebarWeight": "Extras de armătură (greutate pe Ø)" }, "preview": "Previzualizare", "exportCsv": "Exportă CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (nativ)", "symbol": "Simbol", "image": "Imagine", - "other": "Altele" + "other": "Altele", + "reinforcement": "Armătură" }, "field": { "category": "Categorie", @@ -671,7 +674,16 @@ "width": "Lățime", "height": "Înălțime", "location": "Locație", - "locationSource": "Sursa locației" + "locationSource": "Sursa locației", + "rebarMark": "Poziție", + "rebarDiameter": "Ø", + "rebarShape": "Formă de fasonare", + "rebarGrade": "Clasa oțelului", + "rebarCount": "Număr de bare", + "rebarLength": "Lungime bară", + "rebarTotalLength": "Lungime totală", + "rebarMassPerM": "Masă pe metru", + "rebarMass": "Masă" }, "type": { "line": "Linie", @@ -709,7 +721,8 @@ "stavenreeks": "Serie de bare", "betonbalk": "Grindă de beton", "scheduleTable": "Tabel de cantități", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Bară de armătură" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ru/properties.json b/open-pdf-studio/js/i18n/locales/ru/properties.json index a00839c38..23e742587 100644 --- a/open-pdf-studio/js/i18n/locales/ru/properties.json +++ b/open-pdf-studio/js/i18n/locales/ru/properties.json @@ -483,7 +483,9 @@ "symbol": "Спецификация символов", "text": "Спецификация текстов", "full": "Полные количества", - "image": "Изображения и штампы" + "image": "Изображения и штампы", + "bendingSchedule": "Ведомость гибки", + "rebarWeight": "Ведомость арматуры (масса по Ø)" }, "preview": "Предпросмотр", "exportCsv": "Экспорт в CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Текст (встроенный)", "symbol": "Символ", "image": "Изображение", - "other": "Прочее" + "other": "Прочее", + "reinforcement": "Армирование" }, "field": { "category": "Категория", @@ -671,7 +674,16 @@ "width": "Ширина", "height": "Высота", "location": "Расположение", - "locationSource": "Источник расположения" + "locationSource": "Источник расположения", + "rebarMark": "Позиция", + "rebarDiameter": "Ø", + "rebarShape": "Форма гибки", + "rebarGrade": "Класс стали", + "rebarCount": "Количество стержней", + "rebarLength": "Длина стержня", + "rebarTotalLength": "Общая длина", + "rebarMassPerM": "Масса на метр", + "rebarMass": "Масса" }, "type": { "line": "Линия", @@ -709,7 +721,8 @@ "stavenreeks": "Ряд стержней", "betonbalk": "Бетонная балка", "scheduleTable": "Таблица количеств", - "builtinText": "Текст" + "builtinText": "Текст", + "rebarPosition": "Арматурный стержень" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/sk/properties.json b/open-pdf-studio/js/i18n/locales/sk/properties.json index 44f4548e0..0ca43a17d 100644 --- a/open-pdf-studio/js/i18n/locales/sk/properties.json +++ b/open-pdf-studio/js/i18n/locales/sk/properties.json @@ -483,7 +483,9 @@ "symbol": "Výkaz symbolov", "text": "Výkaz textov", "full": "Úplný výkaz výmer", - "image": "Obrázky a pečiatky" + "image": "Obrázky a pečiatky", + "bendingSchedule": "Výpis ohybov", + "rebarWeight": "Výkaz výstuže (hmotnosť podľa Ø)" }, "preview": "Náhľad", "exportCsv": "Exportovať CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (natívny)", "symbol": "Symbol", "image": "Obrázok", - "other": "Ostatné" + "other": "Ostatné", + "reinforcement": "Výstuž" }, "field": { "category": "Kategória", @@ -671,7 +674,16 @@ "width": "Šírka", "height": "Výška", "location": "Umiestnenie", - "locationSource": "Zdroj umiestnenia" + "locationSource": "Zdroj umiestnenia", + "rebarMark": "Pozícia", + "rebarDiameter": "Ø", + "rebarShape": "Tvar ohybu", + "rebarGrade": "Trieda ocele", + "rebarCount": "Počet prútov", + "rebarLength": "Dĺžka prúta", + "rebarTotalLength": "Celková dĺžka", + "rebarMassPerM": "Hmotnosť na meter", + "rebarMass": "Hmotnosť" }, "type": { "line": "Čiara", @@ -709,7 +721,8 @@ "stavenreeks": "Rad prútov", "betonbalk": "Betónový trám", "scheduleTable": "Tabuľka výmer", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Výstužný prút" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/sr/properties.json b/open-pdf-studio/js/i18n/locales/sr/properties.json index 08be35a21..503a3edfa 100644 --- a/open-pdf-studio/js/i18n/locales/sr/properties.json +++ b/open-pdf-studio/js/i18n/locales/sr/properties.json @@ -483,7 +483,9 @@ "symbol": "Спецификација симбола", "text": "Спецификација текстова", "full": "Потпуне количине", - "image": "Слике и печати" + "image": "Слике и печати", + "bendingSchedule": "Спецификација савијања", + "rebarWeight": "Спецификација арматуре (маса по Ø)" }, "preview": "Преглед", "exportCsv": "Извези CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Текст (изворни)", "symbol": "Симбол", "image": "Слика", - "other": "Остало" + "other": "Остало", + "reinforcement": "Арматура" }, "field": { "category": "Категорија", @@ -671,7 +674,16 @@ "width": "Ширина", "height": "Висина", "location": "Локација", - "locationSource": "Извор локације" + "locationSource": "Извор локације", + "rebarMark": "Позиција", + "rebarDiameter": "Ø", + "rebarShape": "Облик савијања", + "rebarGrade": "Квалитет челика", + "rebarCount": "Број шипки", + "rebarLength": "Дужина шипке", + "rebarTotalLength": "Укупна дужина", + "rebarMassPerM": "Маса по метру", + "rebarMass": "Маса" }, "type": { "line": "Линија", @@ -709,7 +721,8 @@ "stavenreeks": "Низ шипки", "betonbalk": "Бетонска греда", "scheduleTable": "Табела количина", - "builtinText": "Текст" + "builtinText": "Текст", + "rebarPosition": "Арматурна шипка" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/sv/properties.json b/open-pdf-studio/js/i18n/locales/sv/properties.json index 92796d053..a8f7b3e95 100644 --- a/open-pdf-studio/js/i18n/locales/sv/properties.json +++ b/open-pdf-studio/js/i18n/locales/sv/properties.json @@ -483,7 +483,9 @@ "symbol": "Symbolförteckning", "text": "Textförteckning", "full": "Fullständiga mängder", - "image": "Bilder och stämplar" + "image": "Bilder och stämplar", + "bendingSchedule": "Bockningslista", + "rebarWeight": "Armeringsförteckning (vikt per Ø)" }, "preview": "Förhandsvisning", "exportCsv": "Exportera CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Text (inbyggd)", "symbol": "Symbol", "image": "Bild", - "other": "Övrigt" + "other": "Övrigt", + "reinforcement": "Armering" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Bredd", "height": "Höjd", "location": "Plats", - "locationSource": "Platskälla" + "locationSource": "Platskälla", + "rebarMark": "Positionsnummer", + "rebarDiameter": "Ø", + "rebarShape": "Bockningsform", + "rebarGrade": "Stålkvalitet", + "rebarCount": "Antal stänger", + "rebarLength": "Stånglängd", + "rebarTotalLength": "Total längd", + "rebarMassPerM": "Vikt per meter", + "rebarMass": "Vikt" }, "type": { "line": "Linje", @@ -709,7 +721,8 @@ "stavenreeks": "Stångserie", "betonbalk": "Betongbalk", "scheduleTable": "Mängdtabell", - "builtinText": "Text" + "builtinText": "Text", + "rebarPosition": "Armeringsjärn" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/sw/properties.json b/open-pdf-studio/js/i18n/locales/sw/properties.json index 64db71b2d..d6ed0071e 100644 --- a/open-pdf-studio/js/i18n/locales/sw/properties.json +++ b/open-pdf-studio/js/i18n/locales/sw/properties.json @@ -483,7 +483,9 @@ "symbol": "Jedwali la ishara", "text": "Jedwali la maandishi", "full": "Viwango kamili", - "image": "Picha na mihuri" + "image": "Picha na mihuri", + "bendingSchedule": "Jedwali la kupinda nondo", + "rebarWeight": "Jedwali la uzito wa nondo (kwa Ø)" }, "preview": "Hakikisho", "exportCsv": "Hamisha CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Maandishi (asili)", "symbol": "Alama", "image": "Picha", - "other": "Nyingine" + "other": "Nyingine", + "reinforcement": "Uimarishaji (nondo)" }, "field": { "category": "Kategoria", @@ -671,7 +674,16 @@ "width": "Upana", "height": "Kimo", "location": "Mahali", - "locationSource": "Chanzo cha mahali" + "locationSource": "Chanzo cha mahali", + "rebarMark": "Namba ya nondo", + "rebarDiameter": "Ø", + "rebarShape": "Umbo la kupinda", + "rebarGrade": "Daraja la chuma", + "rebarCount": "Idadi ya nondo", + "rebarLength": "Urefu wa nondo", + "rebarTotalLength": "Jumla ya urefu", + "rebarMassPerM": "Uzito kwa mita", + "rebarMass": "Uzito" }, "type": { "line": "Mstari", @@ -709,7 +721,8 @@ "stavenreeks": "Mfululizo wa nondo", "betonbalk": "Boriti ya zege", "scheduleTable": "Jedwali la viwango", - "builtinText": "Maandishi" + "builtinText": "Maandishi", + "rebarPosition": "Nondo" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ta/properties.json b/open-pdf-studio/js/i18n/locales/ta/properties.json index e0865324b..8d3b4ee96 100644 --- a/open-pdf-studio/js/i18n/locales/ta/properties.json +++ b/open-pdf-studio/js/i18n/locales/ta/properties.json @@ -483,7 +483,9 @@ "symbol": "குறியீட்டு அட்டவணை", "text": "உரை அட்டவணை", "full": "முழு அளவுகள்", - "image": "படங்கள் & முத்திரைகள்" + "image": "படங்கள் & முத்திரைகள்", + "bendingSchedule": "கம்பி வளைப்பு அட்டவணை", + "rebarWeight": "கம்பி எடை அட்டவணை (Ø வாரியாக)" }, "preview": "முன்னோட்டம்", "exportCsv": "CSV ஏற்றுமதி", @@ -643,7 +645,8 @@ "textBuiltIn": "உரை (உள்ளமைந்தது)", "symbol": "குறியீடு", "image": "படம்", - "other": "மற்றவை" + "other": "மற்றவை", + "reinforcement": "வலுவூட்டல்" }, "field": { "category": "வகைப்பாடு", @@ -671,7 +674,16 @@ "width": "அகலம்", "height": "உயரம்", "location": "இடம்", - "locationSource": "இட மூலம்" + "locationSource": "இட மூலம்", + "rebarMark": "கம்பி எண்", + "rebarDiameter": "Ø", + "rebarShape": "வளைவு வடிவம்", + "rebarGrade": "எஃகு தரம்", + "rebarCount": "கம்பிகளின் எண்ணிக்கை", + "rebarLength": "கம்பி நீளம்", + "rebarTotalLength": "மொத்த நீளம்", + "rebarMassPerM": "மீட்டருக்கு நிறை", + "rebarMass": "நிறை" }, "type": { "line": "கோடு", @@ -709,7 +721,8 @@ "stavenreeks": "கம்பித் தொடர்", "betonbalk": "கான்கிரீட் உத்திரம்", "scheduleTable": "அளவு அட்டவணை", - "builtinText": "உரை" + "builtinText": "உரை", + "rebarPosition": "வலுவூட்டல் கம்பி" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/th/properties.json b/open-pdf-studio/js/i18n/locales/th/properties.json index a4eb62917..cd1159edf 100644 --- a/open-pdf-studio/js/i18n/locales/th/properties.json +++ b/open-pdf-studio/js/i18n/locales/th/properties.json @@ -483,7 +483,9 @@ "symbol": "ตารางรายการสัญลักษณ์", "text": "ตารางรายการข้อความ", "full": "ปริมาณทั้งหมด", - "image": "รูปภาพและตราประทับ" + "image": "รูปภาพและตราประทับ", + "bendingSchedule": "ตารางการดัดเหล็ก", + "rebarWeight": "ตารางน้ำหนักเหล็กเสริม (ตาม Ø)" }, "preview": "ตัวอย่าง", "exportCsv": "ส่งออก CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "ข้อความ (ในตัว)", "symbol": "สัญลักษณ์", "image": "รูปภาพ", - "other": "อื่นๆ" + "other": "อื่นๆ", + "reinforcement": "เหล็กเสริม" }, "field": { "category": "หมวดหมู่", @@ -671,7 +674,16 @@ "width": "ความกว้าง", "height": "ความสูง", "location": "ตำแหน่ง", - "locationSource": "แหล่งที่มาของตำแหน่ง" + "locationSource": "แหล่งที่มาของตำแหน่ง", + "rebarMark": "หมายเลขเหล็ก", + "rebarDiameter": "Ø", + "rebarShape": "รูปแบบการดัด", + "rebarGrade": "ชั้นคุณภาพเหล็ก", + "rebarCount": "จำนวนเส้น", + "rebarLength": "ความยาวเหล็กเสริม", + "rebarTotalLength": "ความยาวรวม", + "rebarMassPerM": "น้ำหนักต่อเมตร", + "rebarMass": "น้ำหนัก" }, "type": { "line": "เส้น", @@ -709,7 +721,8 @@ "stavenreeks": "ชุดเหล็กเสริม", "betonbalk": "คานคอนกรีต", "scheduleTable": "ตารางปริมาณ", - "builtinText": "ข้อความ" + "builtinText": "ข้อความ", + "rebarPosition": "ท่อนเหล็กเสริม" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/tr/properties.json b/open-pdf-studio/js/i18n/locales/tr/properties.json index b3d012ce5..72acdc9b6 100644 --- a/open-pdf-studio/js/i18n/locales/tr/properties.json +++ b/open-pdf-studio/js/i18n/locales/tr/properties.json @@ -483,7 +483,9 @@ "symbol": "Sembol çizelgesi", "text": "Metin çizelgesi", "full": "Tam metraj", - "image": "Görüntüler ve damgalar" + "image": "Görüntüler ve damgalar", + "bendingSchedule": "Bükme listesi", + "rebarWeight": "Donatı metrajı (Ø'ya göre ağırlık)" }, "preview": "Önizleme", "exportCsv": "CSV dışa aktar", @@ -643,7 +645,8 @@ "textBuiltIn": "Metin (yerleşik)", "symbol": "Sembol", "image": "Görüntü", - "other": "Diğer" + "other": "Diğer", + "reinforcement": "Donatı" }, "field": { "category": "Kategori", @@ -671,7 +674,16 @@ "width": "Genişlik", "height": "Yükseklik", "location": "Konum", - "locationSource": "Konum kaynağı" + "locationSource": "Konum kaynağı", + "rebarMark": "Poz no", + "rebarDiameter": "Ø", + "rebarShape": "Bükme şekli", + "rebarGrade": "Çelik sınıfı", + "rebarCount": "Çubuk sayısı", + "rebarLength": "Çubuk uzunluğu", + "rebarTotalLength": "Toplam uzunluk", + "rebarMassPerM": "Metre ağırlığı", + "rebarMass": "Ağırlık" }, "type": { "line": "Çizgi", @@ -709,7 +721,8 @@ "stavenreeks": "Çubuk serisi", "betonbalk": "Beton kiriş", "scheduleTable": "Metraj tablosu", - "builtinText": "Metin" + "builtinText": "Metin", + "rebarPosition": "Donatı çubuğu" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/uk/properties.json b/open-pdf-studio/js/i18n/locales/uk/properties.json index 4f7b4caa7..04e041a0a 100644 --- a/open-pdf-studio/js/i18n/locales/uk/properties.json +++ b/open-pdf-studio/js/i18n/locales/uk/properties.json @@ -483,7 +483,9 @@ "symbol": "Специфікація символів", "text": "Специфікація текстів", "full": "Повні обсяги", - "image": "Зображення та штампи" + "image": "Зображення та штампи", + "bendingSchedule": "Відомість гнуття", + "rebarWeight": "Відомість арматури (маса за Ø)" }, "preview": "Попередній перегляд", "exportCsv": "Експорт CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Текст (вбудований)", "symbol": "Символ", "image": "Зображення", - "other": "Інше" + "other": "Інше", + "reinforcement": "Армування" }, "field": { "category": "Категорія", @@ -671,7 +674,16 @@ "width": "Ширина", "height": "Висота", "location": "Розташування", - "locationSource": "Джерело розташування" + "locationSource": "Джерело розташування", + "rebarMark": "Позиція", + "rebarDiameter": "Ø", + "rebarShape": "Форма гнуття", + "rebarGrade": "Клас сталі", + "rebarCount": "Кількість стрижнів", + "rebarLength": "Довжина стрижня", + "rebarTotalLength": "Загальна довжина", + "rebarMassPerM": "Маса на метр", + "rebarMass": "Маса" }, "type": { "line": "Лінія", @@ -709,7 +721,8 @@ "stavenreeks": "Ряд стрижнів", "betonbalk": "Бетонна балка", "scheduleTable": "Таблиця обсягів", - "builtinText": "Текст" + "builtinText": "Текст", + "rebarPosition": "Арматурний стрижень" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/ur/properties.json b/open-pdf-studio/js/i18n/locales/ur/properties.json index ec41a4624..1e612a8b2 100644 --- a/open-pdf-studio/js/i18n/locales/ur/properties.json +++ b/open-pdf-studio/js/i18n/locales/ur/properties.json @@ -483,7 +483,9 @@ "symbol": "علامت شیڈول", "text": "متن شیڈول", "full": "مکمل مقداریں", - "image": "تصاویر اور مہریں" + "image": "تصاویر اور مہریں", + "bendingSchedule": "سریا موڑ شیڈول", + "rebarWeight": "سریا شیڈول (فی Ø وزن)" }, "preview": "پیش منظر", "exportCsv": "CSV برآمد کریں", @@ -643,7 +645,8 @@ "textBuiltIn": "متن (اصل)", "symbol": "علامت", "image": "تصویر", - "other": "دیگر" + "other": "دیگر", + "reinforcement": "سریا بندی" }, "field": { "category": "زمرہ", @@ -671,7 +674,16 @@ "width": "چوڑائی", "height": "اونچائی", "location": "مقام", - "locationSource": "مقام کا ماخذ" + "locationSource": "مقام کا ماخذ", + "rebarMark": "سریے کا نمبر", + "rebarDiameter": "Ø", + "rebarShape": "موڑ کی شکل", + "rebarGrade": "اسٹیل کا درجہ", + "rebarCount": "سریوں کی تعداد", + "rebarLength": "سریے کی لمبائی", + "rebarTotalLength": "کل لمبائی", + "rebarMassPerM": "فی میٹر وزن", + "rebarMass": "وزن" }, "type": { "line": "لکیر", @@ -709,7 +721,8 @@ "stavenreeks": "سریا سیریز", "betonbalk": "کنکریٹ بیم", "scheduleTable": "مقداروں کی جدول", - "builtinText": "متن" + "builtinText": "متن", + "rebarPosition": "سریا" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/vi/properties.json b/open-pdf-studio/js/i18n/locales/vi/properties.json index 57f8e06b0..8ec676d0c 100644 --- a/open-pdf-studio/js/i18n/locales/vi/properties.json +++ b/open-pdf-studio/js/i18n/locales/vi/properties.json @@ -483,7 +483,9 @@ "symbol": "Bảng thống kê ký hiệu", "text": "Bảng thống kê văn bản", "full": "Khối lượng đầy đủ", - "image": "Hình ảnh & con dấu" + "image": "Hình ảnh & con dấu", + "bendingSchedule": "Bảng thống kê uốn thép", + "rebarWeight": "Bảng khối lượng cốt thép (theo Ø)" }, "preview": "Xem trước", "exportCsv": "Xuất CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "Văn bản (gốc)", "symbol": "Ký hiệu", "image": "Hình ảnh", - "other": "Khác" + "other": "Khác", + "reinforcement": "Cốt thép" }, "field": { "category": "Danh mục", @@ -671,7 +674,16 @@ "width": "Chiều rộng", "height": "Chiều cao", "location": "Vị trí", - "locationSource": "Nguồn vị trí" + "locationSource": "Nguồn vị trí", + "rebarMark": "Số hiệu thanh", + "rebarDiameter": "Ø", + "rebarShape": "Hình dạng uốn", + "rebarGrade": "Mác thép", + "rebarCount": "Số thanh", + "rebarLength": "Chiều dài thanh", + "rebarTotalLength": "Tổng chiều dài", + "rebarMassPerM": "Khối lượng trên mét", + "rebarMass": "Khối lượng" }, "type": { "line": "Đường thẳng", @@ -709,7 +721,8 @@ "stavenreeks": "Dãy cốt thép", "betonbalk": "Dầm bê tông", "scheduleTable": "Bảng khối lượng", - "builtinText": "Văn bản" + "builtinText": "Văn bản", + "rebarPosition": "Thanh cốt thép" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/i18n/locales/zh/properties.json b/open-pdf-studio/js/i18n/locales/zh/properties.json index 918600e83..0b31916b2 100644 --- a/open-pdf-studio/js/i18n/locales/zh/properties.json +++ b/open-pdf-studio/js/i18n/locales/zh/properties.json @@ -483,7 +483,9 @@ "symbol": "符号明细表", "text": "文本明细表", "full": "完整工程量", - "image": "图像和图章" + "image": "图像和图章", + "bendingSchedule": "钢筋弯曲表", + "rebarWeight": "钢筋重量汇总表(按直径)" }, "preview": "预览", "exportCsv": "导出 CSV", @@ -643,7 +645,8 @@ "textBuiltIn": "文本(原生)", "symbol": "符号", "image": "图像", - "other": "其他" + "other": "其他", + "reinforcement": "配筋" }, "field": { "category": "类别", @@ -671,7 +674,16 @@ "width": "宽度", "height": "高度", "location": "位置", - "locationSource": "位置来源" + "locationSource": "位置来源", + "rebarMark": "钢筋编号", + "rebarDiameter": "Ø", + "rebarShape": "弯曲形状", + "rebarGrade": "钢材等级", + "rebarCount": "根数", + "rebarLength": "钢筋长度", + "rebarTotalLength": "总长度", + "rebarMassPerM": "每米质量", + "rebarMass": "质量" }, "type": { "line": "线条", @@ -709,7 +721,8 @@ "stavenreeks": "钢筋组", "betonbalk": "混凝土梁", "scheduleTable": "工程量表", - "builtinText": "文本" + "builtinText": "文本", + "rebarPosition": "钢筋" } }, "parametricSymbol": { diff --git a/open-pdf-studio/js/pdf/loader/annotation-converter.js b/open-pdf-studio/js/pdf/loader/annotation-converter.js index dfd0af536..eafd47e63 100644 --- a/open-pdf-studio/js/pdf/loader/annotation-converter.js +++ b/open-pdf-studio/js/pdf/loader/annotation-converter.js @@ -1585,6 +1585,8 @@ async function converteerPdfAnnotatie(annot, pageNum, viewport, stampImageMap, a count: extraColors.srCount ?? 3, diameter: extraColors.srDiameter ?? 12, barLengthMm: extraColors.srBarLengthMm ?? 0, + barMark: extraColors.srBarMark || '', + steelGrade: extraColors.srSteelGrade || '', legDir: extraColors.srLegDir || 'down-left', legLength: extraColors.srLegLength ?? STAVENREEKS_DEFAULTS.legLength, lineTail: extraColors.srLineTail ?? STAVENREEKS_DEFAULTS.lineTail, diff --git a/open-pdf-studio/js/pdf/loader/color-extraction.js b/open-pdf-studio/js/pdf/loader/color-extraction.js index 4d2a5244b..0644d6bc4 100644 --- a/open-pdf-studio/js/pdf/loader/color-extraction.js +++ b/open-pdf-studio/js/pdf/loader/color-extraction.js @@ -710,6 +710,10 @@ export async function extractAnnotationColors(pageNum, pdfDoc) { if (srLd) colors.srLegDir = srLd; const srLs = srStr('OPS_SRLabelSide'); if (srLs) colors.srLabelSide = srLs; + const srBm = srStr('OPS_SRBarMark'); + if (srBm) colors.srBarMark = srBm; + const srSg = srStr('OPS_SRSteelGrade'); + if (srSg) colors.srSteelGrade = srSg; const srGeom = srArr('OPS_SRGeom'); if (srGeom && srGeom.length === 4) colors.srGeom = srGeom; const srRect = srArr('OPS_SRRect'); diff --git a/open-pdf-studio/js/pdf/saver.js b/open-pdf-studio/js/pdf/saver.js index 805e5cb67..caf0e455a 100644 --- a/open-pdf-studio/js/pdf/saver.js +++ b/open-pdf-studio/js/pdf/saver.js @@ -2324,6 +2324,9 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom OPS_SRCount: srGeom.params.count, OPS_SRDiameter: srGeom.params.diameter, OPS_SRBarLengthMm: srGeom.params.barLengthMm, + // Staafnummer en staalkwaliteit voor de buigstaat (leeg = automatisch). + ...(String(ann.barMark ?? '').trim() ? { OPS_SRBarMark: pdfTextString(String(ann.barMark).trim()) } : {}), + ...(String(ann.steelGrade ?? '').trim() ? { OPS_SRSteelGrade: pdfTextString(String(ann.steelGrade).trim()) } : {}), OPS_SRLegDir: pdfTextString(srGeom.params.legDir), OPS_SRLegLength: srGeom.params.legLength, OPS_SRLineTail: srGeom.params.lineTail, diff --git a/open-pdf-studio/js/pdf/wapening-rondgang.test.mjs b/open-pdf-studio/js/pdf/wapening-rondgang.test.mjs new file mode 100644 index 000000000..e59777ba5 --- /dev/null +++ b/open-pdf-studio/js/pdf/wapening-rondgang.test.mjs @@ -0,0 +1,85 @@ +// Opslaan en heropenen van de constructieve tekengereedschappen (#312): de +// parameters van elk wapenings- en staalverbindingssymbool komen identiek +// terug, en de buigstaat uit het heropende bestand is gelijk aan die van +// vóór het opslaan. Echte saver en echte lader (pdf/rondgang.testhulp.mjs). +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const { leegBlad, opslaan, laad } = await import('./rondgang.testhulp.mjs'); +const { getTemplate, defaultParams } = await import('../symbols/registry.js'); +const { buigstaatRegels, stavenVanAnnotaties } = await import('../quantities/wapening.js'); + +const symbool = (id, symbolId, params, x, y) => { + const tpl = getTemplate(symbolId); + return { + page: 1, rotation: 0, color: '#000000', strokeColor: '#000000', lineWidth: 1, opacity: 1, author: 'User', + id, type: 'parametricSymbol', symbolId, x, y, width: 120, height: 70, + params: { ...defaultParams(tpl), ...params }, + }; +}; + +const MODELLEN = [ + symbool('detail', 'wapeningsdetail', { staafnummer: '1', aantal: 8, diameter: 12, vormcode: '11', a: 2400, b: 350 }, 40, 40), + symbool('beugel', 'wapeningsdetail', { staafnummer: '2', aantal: 30, diameter: 8, afstand: 200, vormcode: '51', a: 250, b: 450, haaklengte: 90 }, 200, 40), + symbool('staaf', 'staaf-plattegrond', { staafnummer: '3', diameter: 10, afstand: 150, verdeelbreedte: 2100, haken: 'beide' }, 40, 160), + symbool('net', 'wapeningsnet', { laag: 'boven', aantalNetten: 2, staafnummerL: '4', staafnummerB: '5' }, 200, 160), + symbool('rand', 'vloer-vorstrand', { lengte: 8000, staafnummerStart: '6', netLagen: 'onder' }, 40, 300), + symbool('korf', 'wapeningskorf', { lengte: 5000, staafnummerStart: '8', staalkwaliteit: 'B500C' }, 200, 300), + symbool('kop', 'kopplaat', { profiel: 'HEA 240', aanzicht: 'kop', overstek: 50, boutRijen: 4, boutklasse: '10.9' }, 40, 450), + symbool('voet', 'voetplaat', { profiel: 'HEB 300', aanzicht: 'zij', ankerAantal: '6', ankerLengte: 450 }, 200, 450), +]; + +const ALLE = [...new Set(MODELLEN.map((m) => m.symbolId))]; + +test('elk symbool komt met identieke parameters terug', async () => { + const bytes = await opslaan(await leegBlad(), MODELLEN); + const terug = (await laad(bytes)).filter((m) => m.type === 'parametricSymbol'); + assert.equal(terug.length, MODELLEN.length); + for (const origineel of MODELLEN) { + const gevonden = terug.filter((m) => m.symbolId === origineel.symbolId + && JSON.stringify(m.params) === JSON.stringify(origineel.params)); + assert.equal(gevonden.length, 1, `${origineel.id} (${origineel.symbolId}) niet ongewijzigd terug`); + assert.ok(Math.abs(gevonden[0].x - origineel.x) < 0.01 && Math.abs(gevonden[0].y - origineel.y) < 0.01, `${origineel.id}: positie`); + } + assert.deepEqual([...new Set(terug.map((m) => m.symbolId))].sort(), ALLE.sort()); +}); + +test('de buigstaat uit het heropende bestand is gelijk', async () => { + const voor = buigstaatRegels(stavenVanAnnotaties(MODELLEN, getTemplate)); + const bytes = await opslaan(await leegBlad(), MODELLEN); + const na = buigstaatRegels(stavenVanAnnotaties(await laad(bytes), getTemplate)); + const kern = (r) => [r.staafnummer, r.diameter, r.vormcode, r.lengteMm, r.aantal, r.staalkwaliteit]; + assert.ok(voor.length >= 10, `verwacht minstens 10 posities, kreeg ${voor.length}`); + assert.deepEqual(na.map(kern), voor.map(kern)); + const kg = (lijst) => lijst.reduce((s, r) => s + r.massaKg, 0); + assert.ok(Math.abs(kg(na) - kg(voor)) < 1e-9); +}); + +test('de IFC-categorie gaat mee naar het bestand', async () => { + const bytes = await opslaan(await leegBlad(), MODELLEN.map((m) => ({ ...m, ifcCategory: m.symbolId === 'wapeningsnet' ? 'IfcReinforcingMesh' : '' }))); + const net = (await laad(bytes)).find((m) => m.symbolId === 'wapeningsnet'); + assert.equal(net.ifcCategory, 'IfcReinforcingMesh'); + const kop = (await laad(bytes)).find((m) => m.symbolId === 'kopplaat'); + assert.equal(kop.ifcCategory, 'IfcPlate', 'afgeleid uit het symbool-id'); +}); + +test('stavenreeks: staafnummer en staalkwaliteit gaan mee naar het bestand en de buigstaat', async () => { + const reeks = (id, extra) => ({ + page: 1, rotation: 0, color: '#000000', strokeColor: '#000000', lineWidth: 1, opacity: 1, author: 'User', + id, type: 'stavenreeks', startX: 60, startY: 600, endX: 200, endY: 600, + count: 6, diameter: 16, barLengthMm: 4200, legDir: 'down-left', legLength: 36, lineTail: 12, fontSize: 10, labelSide: 'end', + ...extra, + }); + const modellen = [ + reeks('r1', { barMark: '12', steelGrade: 'B500C' }), + reeks('r2', { startY: 640, endY: 640, barLengthMm: 0 }), + ]; + const terug = (await laad(await opslaan(await leegBlad(), modellen))).filter((m) => m.type === 'stavenreeks'); + assert.equal(terug.length, 2); + const r1 = terug.find((m) => m.barLengthMm === 4200); + assert.equal(r1.barMark, '12'); + assert.equal(r1.steelGrade, 'B500C'); + const regels = buigstaatRegels(stavenVanAnnotaties(terug, getTemplate)); + assert.deepEqual(regels.map((r) => [r.staafnummer, r.diameter, r.lengteMm, r.aantal, r.staalkwaliteit]), + [['12', 16, 4200, 6, 'B500C']], 'de reeks zonder staaflengte komt niet in de staat'); +}); diff --git a/open-pdf-studio/js/quantities/categories.js b/open-pdf-studio/js/quantities/categories.js index 2000c23fa..ad0592c63 100644 --- a/open-pdf-studio/js/quantities/categories.js +++ b/open-pdf-studio/js/quantities/categories.js @@ -32,6 +32,7 @@ const CATEGORY_KEYS = { 'count': 'count', 'symbol': 'symbol', 'image': 'image', + 'reinforcement': 'reinforcement', 'other': 'other', }; @@ -43,6 +44,7 @@ export const CATEGORY_FALLBACKS = { 'count': 'Count', 'symbol': 'Symbol', 'image': 'Image', + 'reinforcement': 'Reinforcement', 'other': 'Other', }; @@ -54,7 +56,7 @@ export function categoryLabel(cat) { } export const CATEGORY_ORDER = [ - 'area', 'line-based', 'count', 'text-annotation', 'text-built-in', 'symbol', 'image', 'other', + 'area', 'line-based', 'count', 'reinforcement', 'text-annotation', 'text-built-in', 'symbol', 'image', 'other', ]; const TYPE_TO_CATEGORY = { @@ -88,6 +90,8 @@ export const TYPE_NAMES = { stavenreeks: 'Bar series', betonbalk: 'Concrete beam', scheduleTable: 'Quantities table', builtinText: 'Text', + // Wapening: één buigstaat-regel (pseudo-element uit quantities/wapening.js). + rebarPosition: 'Reinforcement bar', }; /** Weergavenaam van een elementtype in de actieve taal. */ @@ -113,6 +117,15 @@ const F = (key, fallback, kind, get, unit = '', dec, unitOf) => ({ // categorie: geeft null (lege cel) voor elk ander lijnvormig element. const srField = (get) => (el) => (el.type === 'stavenreeks' ? get(el) : null); +// Velden die per regel een eigenschap zijn (diameter, lengte per staaf, +// kg/m): optellen in een subtotaal zou onzin opleveren. +const geenSom = (f) => { f.sum = false; return f; }; + +// Buigstaat-regel (pseudo-element 'rebarPosition', zie quantities/wapening.js). +const rb = (get) => (el) => (el.rebar ? get(el.rebar) : null); +// Een numeriek staafnummer als getal, zodat 2 vóór 10 sorteert. +const staafnummerWaarde = (r) => (/^\d+$/.test(r.staafnummer) ? Number(r.staafnummer) : r.staafnummer); + const MM2 = 'mm\u00B2'; const M2 = 'm\u00B2'; @@ -317,6 +330,17 @@ export const FIELD_REGISTRY = { F('width', 'Width', 'number', el => el.originalWidth || el.width || 0, 'px', 0), F('height', 'Height', 'number', el => el.originalHeight || el.height || 0, 'px', 0), ], + 'reinforcement': [...COMMON, + F('rebarMark', 'Bar mark', 'text', rb(staafnummerWaarde)), + geenSom(F('rebarDiameter', 'Ø', 'number', rb(r => r.diameter), 'mm', 0)), + F('rebarShape', 'Shape code', 'text', rb(r => r.vormcode)), + F('rebarGrade', 'Steel grade', 'text', rb(r => r.staalkwaliteit)), + F('rebarCount', 'Number of bars', 'number', rb(r => r.aantal), '', 0), + geenSom(F('rebarLength', 'Bar length', 'number', rb(r => r.lengteMm), 'mm', 0)), + F('rebarTotalLength', 'Total length', 'number', rb(r => r.totaleLengteM), 'm', 2), + geenSom(F('rebarMassPerM', 'Mass per metre', 'number', rb(r => r.massaPerMeter), 'kg/m', 3)), + F('rebarMass', 'Mass', 'number', rb(r => r.massaKg), 'kg', 1), + ], 'other': [...COMMON], }; diff --git a/open-pdf-studio/js/quantities/engine.js b/open-pdf-studio/js/quantities/engine.js index 225df5b1d..e6e12baa8 100644 --- a/open-pdf-studio/js/quantities/engine.js +++ b/open-pdf-studio/js/quantities/engine.js @@ -38,8 +38,9 @@ function applyFmt(f, fmt, elements) { unit: eenheidVoor(f, elements), decimals: f.dec != null ? f.dec : (f.kind === 'number' ? 2 : 0), align: f.kind === 'number' ? 'right' : 'left', - // Beeldkolommen tellen nooit mee in som/subtotalen. - total: !isImage, + // Beeldkolommen tellen nooit mee in som/subtotalen; een veld met + // sum: false (diameter, lengte per staaf) ook niet. + total: !isImage && f.sum !== false, }; if (isImage) return { ...base, total: false }; if (!fmt) return base; @@ -49,7 +50,7 @@ function applyFmt(f, fmt, elements) { unit: fmt.unit != null ? fmt.unit : base.unit, decimals: fmt.decimals != null ? fmt.decimals : base.decimals, align: fmt.align || base.align, - total: fmt.total !== false, + total: fmt.total != null ? fmt.total !== false : base.total, }; } diff --git a/open-pdf-studio/js/quantities/schedule-templates.js b/open-pdf-studio/js/quantities/schedule-templates.js index 5881b0d64..daece09e9 100644 --- a/open-pdf-studio/js/quantities/schedule-templates.js +++ b/open-pdf-studio/js/quantities/schedule-templates.js @@ -67,6 +67,32 @@ export const STANDARD_SCHEDULE_TEMPLATES = [ itemize: true, }, }, + { + // Buigstaat (bending schedule): één regel per staafnummer met Ø, vorm, + // lengte, aantal, totale lengte en gewicht. Gevuld uit de wapenings- + // regels van quantities/wapening.js (stavenreeks + wapeningssymbolen). + id: 'buigstaat', + nameKey: 'schedules.tpl.bendingSchedule', + config: { + categories: ['reinforcement'], + fields: ['rebarMark', 'rebarDiameter', 'rebarShape', 'rebarLength', 'rebarCount', + 'rebarTotalLength', 'rebarMass', 'rebarGrade'], + sort: [{ field: 'rebarMark', dir: 'asc', group: false }], + itemize: true, + }, + }, + { + // Wapeningsstaat (gewicht per diameter): gegroepeerd per Ø, alleen de + // subtotalen van lengte en gewicht. + id: 'wapeningsstaat', + nameKey: 'schedules.tpl.rebarWeight', + config: { + categories: ['reinforcement'], + fields: ['rebarDiameter', 'rebarMassPerM', 'rebarCount', 'rebarTotalLength', 'rebarMass'], + sort: [{ field: 'rebarDiameter', dir: 'asc', group: true, header: true, footer: true }], + itemize: false, + }, + }, { id: 'full', nameKey: 'schedules.tpl.full', diff --git a/open-pdf-studio/js/quantities/wapening.js b/open-pdf-studio/js/quantities/wapening.js new file mode 100644 index 000000000..3d61de6a5 --- /dev/null +++ b/open-pdf-studio/js/quantities/wapening.js @@ -0,0 +1,352 @@ +// Wapening — pure rekenkern voor staven, buigvormen en de buigstaat +// (GEEN UI/DOM-deps). Eén bron voor: +// * de notatie op tekening ("12Ø10-150", "4Ø12"), +// * de staaflengte (knip-/ontwikkelde lengte) per buigvorm, +// * het gewicht per diameter (0,00617·d² kg/m), +// * het verzamelen van staven uit annotaties (stavenreeks, parametrische +// wapeningssymbolen) en het samenvoegen tot buigstaat-regels. +// +// De tekensymbolen (symbols/templates/: wapeningsdetail, staaf-plattegrond, +// wapeningsnet, vloer-vorstrand, wapeningskorf) en de hoeveelhedenstaat +// (quantities/categories.js) rekenen allebei via deze module, zodat de +// tekening en de staat per definitie hetzelfde getal tonen. +// +// Uitgangspunten staan in docs/constructie/wapening-en-staalverbindingen.md +// (in de hoofdmap van de repository). + +import { leesGetal } from '../core/getal-invoer.js'; + +/** Gangbare betonstaalkwaliteiten (NEN 6008 / NEN-EN 10080). */ +export const STAALKWALITEITEN = ['B500B', 'B500A', 'B500C']; +export const STANDAARD_STAALKWALITEIT = 'B500B'; + +/** Leverbare staafdiameters (mm). */ +export const STAAF_DIAMETERS = [6, 8, 10, 12, 16, 20, 25, 32, 40]; + +/** + * Massa per meter in kg/m = MASSA_FACTOR × d² (d in mm). 0,00617 is de + * afgeronde waarde van π/4 · 7850 kg/m³ · 10⁻⁶ (= 0,006165). + */ +export const MASSA_FACTOR = 0.00617; + +/** Afronding van de staaflengte op de buigstaat (naar boven, in mm). */ +export const LENGTE_AFRONDING_MM = 5; + +function num(v, def = 0, min = -Infinity) { + const n = typeof v === 'string' ? leesGetal(v) : Number(v); + return Number.isFinite(n) && n >= min ? n : def; +} + +function geheel(v, def = 0, min = 0) { + return Math.max(min, Math.round(num(v, def, min))); +} + +function compact(v) { + const n = Number(v); + if (!Number.isFinite(n)) return ''; + return Number.isInteger(n) ? String(n) : String(Number(n.toFixed(1))); +} + +/** kg/m voor staafdiameter d (mm). */ +export function massaPerMeter(diameter) { + const d = num(diameter, 0, 0); + return MASSA_FACTOR * d * d; +} + +/** + * Doorndiameter (binnendiameter van de buiging) volgens NEN-EN 1992-1-1 + * tabel 8.1N voor staven en beugels: Ø ≤ 16 → 4Ø, Ø > 16 → 7Ø. + */ +export function buigdoornDiameter(diameter) { + const d = num(diameter, 0, 0); + return d <= 16 ? 4 * d : 7 * d; +} + +/** + * Notatie van een staaf of reeks op tekening: + * aantal + afstand → "12Ø10-150" + * alleen aantal → "4Ø12" + * alleen afstand → "Ø10-150" + */ +export function staafNotatie({ aantal, diameter, afstand } = {}) { + const n = geheel(aantal, 0, 0); + const d = compact(num(diameter, 0, 0)); + const s = num(afstand, 0, 0); + return `${n > 0 ? n : ''}Ø${d}${s > 0 ? `-${compact(s)}` : ''}`; +} + +/** Volledig staaflabel: "12Ø10-150 lg=2450" (lengte optioneel). */ +export function staafLabel({ aantal, diameter, afstand, lengteMm } = {}) { + const basis = staafNotatie({ aantal, diameter, afstand }); + const L = num(lengteMm, 0, 0); + return L > 0 ? `${basis} lg=${compact(L)}` : basis; +} + +/** Aantal staven over een verdeelbreedte bij een h.o.h.-afstand (incl. beide randen). */ +export function aantalOverBreedte(breedteMm, afstandMm) { + const b = num(breedteMm, 0, 0); + const s = num(afstandMm, 0, 0); + if (!(s > 0) || !(b >= 0)) return 0; + return Math.floor(b / s + 1e-9) + 1; +} + +// ─── Buigvormen ────────────────────────────────────────────────────────── +// Vormcodes volgen het nummerprincipe van NEN-EN ISO 3766: het eerste cijfer +// is het aantal bochten, het tweede het type. Maten zijn BUITENmaten. + +export const BUIGVORMEN = { + '00': { naam: 'Recht', naamEn: 'Straight', segmenten: ['a'] }, + '11': { naam: 'Haakstaaf (L, 90°)', naamEn: 'L-bar (90°)', segmenten: ['a', 'b'] }, + '21': { naam: 'U-staaf (2 × 90°)', naamEn: 'U-bar (2 × 90°)', segmenten: ['a', 'b', 'c'] }, + '51': { naam: 'Gesloten beugel (135°-haken)', naamEn: 'Closed stirrup (135° hooks)', segmenten: ['a', 'b'] }, +}; + +// Vaste volgorde: '00' is geen array-index en zou anders achteraan komen. +export const VORMCODES = ['00', '11', '21', '51']; + +export function normaliseerVormcode(code) { + const c = String(code ?? '').trim().padStart(2, '0'); + return BUIGVORMEN[c] ? c : '00'; +} + +/** + * Lengteaftrek per 90°-bocht bij buitenmaten: 2(r+d) − π/2·(r+d/2), + * met r = de halve doorndiameter. + */ +export function bochtAftrek90(diameter, doornDiameter = buigdoornDiameter(diameter)) { + const d = num(diameter, 0, 0); + const r = num(doornDiameter, 0, 0) / 2; + return 2 * (r + d) - (Math.PI / 2) * (r + d / 2); +} + +/** Standaard haakverlengte van een 135°-beugelhaak: 10Ø (≥ EC2-minimum 5Ø, 50 mm). */ +export function standaardHaaklengte(diameter) { + return Math.max(10 * num(diameter, 0, 0), 50); +} + +/** + * Ontwikkelde staaflengte (mm, naar boven afgerond op LENGTE_AFRONDING_MM). + * + * @param {string} vormcode '00' | '11' | '21' | '51' + * @param {{a?:number,b?:number,c?:number}} seg buitenmaten in mm + * @param {number} diameter staafdiameter in mm + * @param {{doorn?:number, haaklengte?:number, afronden?:boolean}} [opts] + */ +export function staafLengte(vormcode, seg = {}, diameter = 10, opts = {}) { + const code = normaliseerVormcode(vormcode); + const d = num(diameter, 10, 0); + const doorn = num(opts.doorn, 0, 0) > 0 ? num(opts.doorn) : buigdoornDiameter(d); + const a = num(seg.a, 0, 0); + const b = num(seg.b, 0, 0); + const c = num(seg.c, 0, 0); + const aftrek = bochtAftrek90(d, doorn); + let L; + switch (code) { + case '11': L = a + b - aftrek; break; + case '21': L = a + b + c - 2 * aftrek; break; + case '51': { + // Rondom vier zijden (buitenmaat a × b), drie hoeken van 90° en twee + // haken van 135° in dezelfde hoek, elk met een rechte verlenging. + const r = doorn / 2; + const haak = num(opts.haaklengte, 0, 0) > 0 ? num(opts.haaklengte) : standaardHaaklengte(d); + const recht = 2 * a + 2 * b - 8 * (r + d); + const boog90 = (Math.PI / 2) * (r + d / 2); + const boog135 = (3 * Math.PI / 4) * (r + d / 2); + L = recht + 3 * boog90 + 2 * (boog135 + haak); + break; + } + default: L = a; + } + L = Math.max(0, L); + if (opts.afronden === false) return L; + return Math.ceil(L / LENGTE_AFRONDING_MM - 1e-9) * LENGTE_AFRONDING_MM; +} + +// ─── Staven verzamelen ─────────────────────────────────────────────────── + +/** + * Eén staafpositie in genormaliseerde vorm. + * @returns {{staafnummer:string, diameter:number, aantal:number, lengteMm:number, + * vormcode:string, staalkwaliteit:string, afstand:number, omschrijving:string}} + */ +export function normaliseerStaaf(s = {}) { + const kw = String(s.staalkwaliteit || '').trim().toUpperCase(); + return { + staafnummer: String(s.staafnummer ?? '').trim(), + diameter: num(s.diameter, 0, 0), + aantal: geheel(s.aantal, 0, 0), + lengteMm: Math.round(num(s.lengteMm, 0, 0)), + vormcode: normaliseerVormcode(s.vormcode), + staalkwaliteit: kw || STANDAARD_STAALKWALITEIT, + afstand: num(s.afstand, 0, 0), + omschrijving: String(s.omschrijving ?? ''), + }; +} + +/** + * Staven uit één annotatie. Een stavenreeks draagt zijn waarden zelf + * (aantal, diameter, staaflengte, staafnummer `barMark` en staalkwaliteit + * `steelGrade`, beide in te stellen in het eigenschappenpaneel); een + * parametrisch symbool levert ze via `template.rebarItems(params)`. Een staaf + * zonder lengte (een stavenreeks zonder staaflengte) komt niet in de staat: + * een regel van 0 mm en 0 kg zegt niets. + * @param {object} ann + * @param {(id:string)=>object|null} getTemplate + */ +export function stavenVanAnnotatie(ann, getTemplate) { + if (!ann || typeof ann !== 'object') return []; + let ruw = []; + if (ann.type === 'stavenreeks') { + ruw = [{ + staafnummer: ann.barMark ?? '', + diameter: ann.diameter ?? 12, + aantal: ann.count ?? 3, + lengteMm: ann.barLengthMm ?? 0, + vormcode: '00', + staalkwaliteit: ann.steelGrade || STANDAARD_STAALKWALITEIT, + }]; + } else if (ann.type === 'parametricSymbol' && typeof getTemplate === 'function') { + const tpl = getTemplate(ann.symbolId); + if (tpl && typeof tpl.rebarItems === 'function') { + try { ruw = tpl.rebarItems(ann.params || {}) || []; } catch { ruw = []; } + } + } + return ruw + .map(normaliseerStaaf) + .filter((s) => s.aantal > 0 && s.diameter > 0 && s.lengteMm > 0) + .map((s) => ({ ...s, bron: { id: ann.id ?? null, page: ann.page || 1, type: ann.type, symbolId: ann.symbolId || null } })); +} + +/** Alle staven uit een annotatielijst. */ +export function stavenVanAnnotaties(annotaties, getTemplate) { + const uit = []; + for (const a of annotaties || []) uit.push(...stavenVanAnnotatie(a, getTemplate)); + return uit; +} + +function vormSleutel(s) { + return `${s.diameter}|${s.vormcode}|${s.lengteMm}|${s.staalkwaliteit}`; +} + +/** + * Geeft staven zonder eigen staafnummer een vrij volgnummer. Gelijke staven + * (zelfde Ø, vorm, lengte en kwaliteit) krijgen hetzelfde nummer; de + * nummering begint na het hoogste handmatige nummer. + */ +export function nummerStaven(staven) { + const lijst = (staven || []).map((s) => ({ ...s })); + let hoogste = 0; + const bestaand = new Map(); + for (const s of lijst) { + if (!s.staafnummer) continue; + const n = Number(s.staafnummer); + if (Number.isInteger(n) && n > hoogste) hoogste = n; + if (!bestaand.has(vormSleutel(s))) bestaand.set(vormSleutel(s), s.staafnummer); + } + const toegekend = new Map(); + for (const s of lijst) { + if (s.staafnummer) continue; + const k = vormSleutel(s); + if (!toegekend.has(k)) { + hoogste += 1; + toegekend.set(k, String(hoogste)); + } + s.staafnummer = toegekend.get(k); + s.automatischNummer = true; + } + return lijst; +} + +function nummerVolgorde(a, b) { + const na = Number(a), nb = Number(b); + const ia = Number.isFinite(na), ib = Number.isFinite(nb); + if (ia && ib) return na - nb; + if (ia) return -1; + if (ib) return 1; + return String(a).localeCompare(String(b)); +} + +/** + * Buigstaat: staven samengevoegd per positie (staafnummer + Ø + vorm + + * lengte + kwaliteit). Draagt één staafnummer verschillende staven, dan + * krijgen die regels `conflict: true`. + * @returns {Array<{staafnummer, diameter, vormcode, lengteMm, aantal, + * staalkwaliteit, totaleLengteM, massaPerMeter, massaKg, paginas, conflict}>} + */ +export function buigstaatRegels(staven) { + const genummerd = nummerStaven(staven); + const regels = new Map(); + for (const s of genummerd) { + const k = `${s.staafnummer}#${vormSleutel(s)}`; + let r = regels.get(k); + if (!r) { + r = { + staafnummer: s.staafnummer, + diameter: s.diameter, + vormcode: s.vormcode, + lengteMm: s.lengteMm, + staalkwaliteit: s.staalkwaliteit, + aantal: 0, + paginas: [], + bronnen: [], + conflict: false, + }; + regels.set(k, r); + } + r.aantal += s.aantal; + const p = s.bron?.page; + if (p && !r.paginas.includes(p)) r.paginas.push(p); + if (s.bron?.id != null && !r.bronnen.includes(s.bron.id)) r.bronnen.push(s.bron.id); + } + const lijst = [...regels.values()]; + const perNummer = new Map(); + for (const r of lijst) perNummer.set(r.staafnummer, (perNummer.get(r.staafnummer) || 0) + 1); + for (const r of lijst) { + r.conflict = perNummer.get(r.staafnummer) > 1; + r.paginas.sort((a, b) => a - b); + r.massaPerMeter = massaPerMeter(r.diameter); + r.totaleLengteM = (r.aantal * r.lengteMm) / 1000; + r.massaKg = r.totaleLengteM * r.massaPerMeter; + } + return lijst.sort((a, b) => nummerVolgorde(a.staafnummer, b.staafnummer) + || a.diameter - b.diameter || a.lengteMm - b.lengteMm); +} + +/** + * Gewichtsstaat per diameter uit buigstaat-regels. + * @returns {{perDiameter: Array<{diameter, totaleLengteM, massaPerMeter, massaKg}>, totaalKg:number, totaleLengteM:number}} + */ +export function gewichtPerDiameter(regels) { + const m = new Map(); + for (const r of regels || []) { + const d = r.diameter; + const lengteM = typeof r.totaleLengteM === 'number' ? r.totaleLengteM : (r.aantal * r.lengteMm) / 1000; + const e = m.get(d) || { diameter: d, totaleLengteM: 0, massaPerMeter: massaPerMeter(d), massaKg: 0 }; + e.totaleLengteM += lengteM; + e.massaKg = e.totaleLengteM * e.massaPerMeter; + m.set(d, e); + } + const perDiameter = [...m.values()].sort((a, b) => a.diameter - b.diameter); + return { + perDiameter, + totaleLengteM: perDiameter.reduce((s, e) => s + e.totaleLengteM, 0), + totaalKg: perDiameter.reduce((s, e) => s + e.massaKg, 0), + }; +} + +/** + * Pseudo-elementen voor de hoeveelheden-engine: één per buigstaat-regel, in + * de categorie 'reinforcement'. Zo vullen de standaardsjablonen "Buigstaat" + * en "Wapeningsstaat" zich met dezelfde regels als buigstaatRegels(). + */ +export function wapeningsElementen(annotaties, getTemplate) { + const regels = buigstaatRegels(stavenVanAnnotaties(annotaties, getTemplate)); + return regels.map((r, i) => ({ + id: `rebar-${r.staafnummer}-${i}`, + __category: 'reinforcement', + type: 'rebarPosition', + page: r.paginas[0] || 1, + label: staafLabel({ aantal: r.aantal, diameter: r.diameter, lengteMm: r.lengteMm }), + rebar: r, + })); +} diff --git a/open-pdf-studio/js/quantities/wapening.test.mjs b/open-pdf-studio/js/quantities/wapening.test.mjs new file mode 100644 index 000000000..a82d4e5fd --- /dev/null +++ b/open-pdf-studio/js/quantities/wapening.test.mjs @@ -0,0 +1,216 @@ +// Rekenkern wapening (#312): notatie, staaflengte per buigvorm, gewicht per Ø +// en de buigstaat/wapeningsstaat uit annotaties via de hoeveelheden-engine. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + massaPerMeter, buigdoornDiameter, staafNotatie, staafLabel, aantalOverBreedte, + bochtAftrek90, staafLengte, normaliseerStaaf, stavenVanAnnotatie, stavenVanAnnotaties, nummerStaven, + buigstaatRegels, gewichtPerDiameter, wapeningsElementen, MASSA_FACTOR, +} from './wapening.js'; +import { buildSchedule } from './engine.js'; +import { getTemplateById, scheduleResultToTable } from './schedule-templates.js'; +import { CATEGORY_ORDER, categoryOf } from './categories.js'; +import { getTemplate } from '../symbols/registry.js'; + +const dicht = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≠ ${b}`); + +test('massa per meter is 0,00617·d² kg/m', () => { + assert.equal(MASSA_FACTOR, 0.00617); + dicht(massaPerMeter(8), 0.39488); + dicht(massaPerMeter(10), 0.617); + dicht(massaPerMeter(12), 0.88848); + dicht(massaPerMeter(16), 1.57952); + dicht(massaPerMeter(25), 3.85625); +}); + +test('doorndiameter volgens NEN-EN 1992-1-1 tabel 8.1N', () => { + assert.equal(buigdoornDiameter(8), 32); + assert.equal(buigdoornDiameter(16), 64); + assert.equal(buigdoornDiameter(20), 140); +}); + +test('Nederlandse staafnotatie', () => { + assert.equal(staafNotatie({ aantal: 12, diameter: 10, afstand: 150 }), '12Ø10-150'); + assert.equal(staafNotatie({ aantal: 4, diameter: 12 }), '4Ø12'); + assert.equal(staafNotatie({ diameter: 8, afstand: 150 }), 'Ø8-150'); + assert.equal(staafLabel({ aantal: 12, diameter: 10, afstand: 150, lengteMm: 2450 }), '12Ø10-150 lg=2450'); + assert.equal(staafLabel({ aantal: 3, diameter: 8 }), '3Ø8'); +}); + +test('aantal staven over een verdeelbreedte (beide randen inbegrepen)', () => { + assert.equal(aantalOverBreedte(1800, 150), 13); + assert.equal(aantalOverBreedte(1000, 300), 4); + assert.equal(aantalOverBreedte(0, 150), 1); + assert.equal(aantalOverBreedte(1000, 0), 0); +}); + +test('staaflengte per buigvorm uit buitenmaten', () => { + // 90°-bocht Ø10, doorn 40: 2(20+10) − π/2·(20+5) + dicht(bochtAftrek90(10), 60 - (Math.PI / 2) * 25); + assert.equal(staafLengte('00', { a: 4000 }, 12), 4000); + dicht(staafLengte('11', { a: 2500, b: 400 }, 10, { afronden: false }), 2900 - bochtAftrek90(10)); + assert.equal(staafLengte('11', { a: 2500, b: 400 }, 10), 2880); + dicht(staafLengte('21', { a: 300, b: 2000, c: 300 }, 10, { afronden: false }), 2600 - 2 * bochtAftrek90(10)); + assert.equal(staafLengte('21', { a: 300, b: 2000, c: 300 }, 10), 2560); + // Beugel 300×500 Ø10: vier zijden, drie 90°-hoeken, twee 135°-haken + 10Ø. + const r = 20, d = 10; + const verwacht = 1600 - 8 * (r + d) + 3 * (Math.PI / 2) * (r + d / 2) + 2 * ((3 * Math.PI / 4) * (r + d / 2) + 100); + dicht(staafLengte('51', { a: 300, b: 500 }, 10, { afronden: false }), verwacht); + assert.equal(staafLengte('51', { a: 300, b: 500 }, 10), 1800); + // Grotere staaf: doorn 7Ø. + dicht(staafLengte('11', { a: 1000, b: 500 }, 20, { afronden: false }), 1500 - (180 - (Math.PI / 2) * 80)); + // Onbekende vorm valt terug op recht. + assert.equal(staafLengte('99', { a: 1234 }, 10), 1235); +}); + +test('normaliseren van een staaf', () => { + const s = normaliseerStaaf({ staafnummer: ' 7 ', diameter: '12', aantal: '3,4', lengteMm: 1999.6, vormcode: '1', staalkwaliteit: 'b500c' }); + assert.deepEqual( + [s.staafnummer, s.diameter, s.aantal, s.lengteMm, s.vormcode, s.staalkwaliteit], + ['7', 12, 3, 2000, '00', 'B500C'], + ); + assert.equal(normaliseerStaaf({}).staalkwaliteit, 'B500B'); +}); + +function ps(id, symbolId, params, page = 1) { + return { id, type: 'parametricSymbol', symbolId, params, page }; +} + +const ANNOTATIES = [ + { id: 'a', type: 'stavenreeks', count: 5, diameter: 12, barLengthMm: 3000, page: 1 }, + ps('b', 'wapeningsdetail', { staafnummer: '1', aantal: 12, diameter: 10, afstand: 150, vormcode: '21', a: 300, b: 2000, c: 300 }), + ps('c', 'wapeningsdetail', { staafnummer: '1', aantal: 12, diameter: 10, afstand: 150, vormcode: '21', a: 300, b: 2000, c: 300 }, 2), + ps('d', 'staaf-plattegrond', { staafnummer: '', diameter: 12, afstand: 150, verdeelbreedte: 1800, lengte: 3000 }), + ps('e', 'wapeningsnet', { lengte: 5000, breedte: 2150, diameterL: 8, afstandL: 150, diameterB: 8, afstandB: 150 }), + { id: 'f', type: 'line', startX: 0, startY: 0, endX: 10, endY: 0, page: 1 }, + ps('g', 'bout', { maat: 'M12' }), +]; + +test('staven uit annotaties: stavenreeks en wapeningssymbolen, andere niet', () => { + assert.equal(stavenVanAnnotatie(ANNOTATIES[0], getTemplate)[0].lengteMm, 3000); + assert.equal(stavenVanAnnotatie(ANNOTATIES[5], getTemplate).length, 0); + assert.equal(stavenVanAnnotatie(ANNOTATIES[6], getTemplate).length, 0); + assert.equal(stavenVanAnnotatie(ps('x', 'onbekend', {}), getTemplate).length, 0); + const net = stavenVanAnnotatie(ANNOTATIES[4], getTemplate); + assert.deepEqual(net.map((s) => [s.aantal, s.lengteMm, s.staalkwaliteit]), [[15, 5000, 'B500A'], [34, 2150, 'B500A']]); + assert.equal(net[0].bron.id, 'e'); +}); + +test('automatische staafnummers na het hoogste handmatige, gelijke staven delen een nummer', () => { + const genummerd = nummerStaven([ + normaliseerStaaf({ staafnummer: '4', diameter: 10, aantal: 1, lengteMm: 100 }), + normaliseerStaaf({ diameter: 12, aantal: 2, lengteMm: 3000 }), + normaliseerStaaf({ diameter: 12, aantal: 3, lengteMm: 3000 }), + normaliseerStaaf({ diameter: 8, aantal: 3, lengteMm: 3000 }), + ]); + assert.deepEqual(genummerd.map((s) => s.staafnummer), ['4', '5', '5', '6']); +}); + +test('buigstaat: samenvoegen per positie, lengte en gewicht', () => { + const regels = buigstaatRegels(ANNOTATIES.flatMap((a) => stavenVanAnnotatie(a, getTemplate))); + assert.deepEqual(regels.map((r) => [r.staafnummer, r.diameter, r.vormcode, r.lengteMm, r.aantal]), [ + ['1', 10, '21', 2560, 24], + ['2', 12, '00', 3000, 18], + ['3', 8, '00', 5000, 15], + ['4', 8, '00', 2150, 34], + ]); + assert.deepEqual(regels[0].paginas, [1, 2]); + dicht(regels[0].totaleLengteM, 61.44); + dicht(regels[0].massaKg, 61.44 * 0.617); + dicht(regels[1].massaKg, 54 * 0.88848); + assert.ok(regels.every((r) => r.conflict === false)); + + const gewicht = gewichtPerDiameter(regels); + assert.deepEqual(gewicht.perDiameter.map((e) => e.diameter), [8, 10, 12]); + dicht(gewicht.perDiameter[0].totaleLengteM, 75 + 73.1); + dicht(gewicht.perDiameter[0].massaKg, 148.1 * 0.39488); + dicht(gewicht.totaalKg, 148.1 * 0.39488 + 61.44 * 0.617 + 54 * 0.88848); +}); + +test('één staafnummer met verschillende staven wordt gemarkeerd', () => { + const regels = buigstaatRegels([ + normaliseerStaaf({ staafnummer: '1', diameter: 10, aantal: 2, lengteMm: 1000 }), + normaliseerStaaf({ staafnummer: '1', diameter: 12, aantal: 2, lengteMm: 1000 }), + ]); + assert.equal(regels.length, 2); + assert.ok(regels.every((r) => r.conflict)); +}); + +test('de categorie wapening staat in de hoeveelheden', () => { + assert.ok(CATEGORY_ORDER.includes('reinforcement')); + const el = wapeningsElementen(ANNOTATIES, getTemplate); + assert.equal(el.length, 4); + assert.ok(el.every((e) => categoryOf(e) === 'reinforcement')); + assert.equal(el[0].label, '24Ø10 lg=2560'); +}); + +test('standaardsjabloon Buigstaat via de engine', () => { + const tpl = getTemplateById('buigstaat'); + const r = buildSchedule([...ANNOTATIES, ...wapeningsElementen(ANNOTATIES, getTemplate)], tpl.config); + assert.equal(r.count, 4); + const rijen = r.groups[0].rows.map((row) => [row.vals.rebarMark, row.vals.rebarDiameter, row.vals.rebarShape, row.vals.rebarLength, row.vals.rebarCount]); + assert.deepEqual(rijen, [[1, 10, '21', 2560, 24], [2, 12, '00', 3000, 18], [3, 8, '00', 5000, 15], [4, 8, '00', 2150, 34]]); + dicht(r.grandTotals.rebarTotalLength, 61.44 + 54 + 75 + 73.1); + dicht(r.grandTotals.rebarMass, 148.1 * 0.39488 + 61.44 * 0.617 + 54 * 0.88848); + assert.equal(r.grandTotals.rebarCount, 24 + 18 + 15 + 34); + // Diameter en lengte per staaf tellen niet op. + assert.equal(r.grandTotals.rebarDiameter, undefined); + assert.equal(r.grandTotals.rebarLength, undefined); + + const tabel = scheduleResultToTable(r, 'Buigstaat'); + assert.deepEqual(tabel.columns.slice(0, 4), ['Bar mark', 'Ø (mm)', 'Shape code', 'Bar length (mm)']); + assert.deepEqual(tabel.rows[0].cells.slice(0, 5), ['1', '10', '21', '2560', '24']); + const totaal = tabel.rows.at(-1); + assert.ok(totaal.grand); + assert.equal(totaal.cells[1], '', 'geen opgetelde diameter'); +}); + +test('standaardsjabloon Wapeningsstaat: gewicht per diameter', () => { + const tpl = getTemplateById('wapeningsstaat'); + const r = buildSchedule(wapeningsElementen(ANNOTATIES, getTemplate), tpl.config); + assert.deepEqual(r.groups.map((g) => g.key), ['8', '10', '12']); + dicht(r.groups[0].subtotals.rebarTotalLength, 148.1); + dicht(r.groups[0].subtotals.rebarMass, 148.1 * 0.39488); + assert.equal(r.groups[0].subtotals.rebarMassPerM, undefined); + assert.equal(r.itemize, false); +}); + +test('een gewone formaat-instelling zet een niet-optelbaar veld niet aan', () => { + const r = buildSchedule(wapeningsElementen(ANNOTATIES, getTemplate), { + categories: ['reinforcement'], fields: ['rebarDiameter', 'rebarMass'], + format: { rebarDiameter: { heading: 'Diam.' } }, + }); + assert.equal(r.columns[0].label, 'Diam.'); + assert.equal(r.columns[0].total, false); + assert.equal(r.columns[1].total, true); +}); + +test('stavenreeks: eigen staafnummer en staalkwaliteit tellen, zonder staaflengte geen regel', () => { + const metNummer = { id: 'r', type: 'stavenreeks', count: 4, diameter: 16, barLengthMm: 2500, barMark: '7', steelGrade: 'b500c', page: 1 }; + const [s] = stavenVanAnnotatie(metNummer, getTemplate); + assert.equal(s.staafnummer, '7'); + assert.equal(s.staalkwaliteit, 'B500C'); + const standaard = stavenVanAnnotatie({ id: 's', type: 'stavenreeks', count: 4, diameter: 16, barLengthMm: 2500, page: 1 }, getTemplate)[0]; + assert.equal(standaard.staafnummer, ''); + assert.equal(standaard.staalkwaliteit, 'B500B'); + // Zonder staaflengte (standaard 0): geen buigstaatregel van 0 mm / 0 kg. + assert.deepEqual(stavenVanAnnotatie({ id: 't', type: 'stavenreeks', count: 4, diameter: 16, page: 1 }, getTemplate), []); + assert.deepEqual(stavenVanAnnotatie({ id: 'u', type: 'stavenreeks', count: 4, diameter: 16, barLengthMm: 0, page: 1 }, getTemplate), []); + const regels = buigstaatRegels(stavenVanAnnotaties([metNummer, { id: 'u', type: 'stavenreeks', count: 9, diameter: 10, page: 1 }], getTemplate)); + assert.deepEqual(regels.map((r) => [r.staafnummer, r.lengteMm, r.staalkwaliteit]), [['7', 2500, 'B500C']]); + assert.ok(regels.every((r) => r.lengteMm > 0 && r.massaKg > 0)); +}); + +test('eigenschappenpaneel draagt staafnummer en staalkwaliteit van een stavenreeks', () => { + const lees = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8').replace(/\r\n/g, '\n'); + const paneel = lees('../solid/components/properties-panel/StavenreeksSection.jsx'); + assert.match(paneel, /updateAnnotProp\('srBarMark'/); + assert.match(paneel, /updateAnnotProp\('srSteelGrade'/); + const store = lees('../solid/stores/propertiesStore.js'); + assert.match(store, /srBarMark: annotation\.barMark/); + assert.match(store, /srSteelGrade: annotation\.steelGrade/); + assert.match(store, /case 'srBarMark':/); + assert.match(store, /case 'srSteelGrade':/); +}); diff --git a/open-pdf-studio/js/solid/components/properties-panel/StavenreeksSection.jsx b/open-pdf-studio/js/solid/components/properties-panel/StavenreeksSection.jsx index c27b1d052..fe057fa4b 100644 --- a/open-pdf-studio/js/solid/components/properties-panel/StavenreeksSection.jsx +++ b/open-pdf-studio/js/solid/components/properties-panel/StavenreeksSection.jsx @@ -9,6 +9,7 @@ import { annotProps, updateAnnotProp } from '../../stores/propertiesStore.js'; import CollapsibleSection from './CollapsibleSection.jsx'; import { useTranslation } from '../../../i18n/useTranslation.js'; import { STAVENREEKS_DIAMETERS, labelText } from '../../../annotations/stavenreeks.js'; +import { STAALKWALITEITEN, STANDAARD_STAALKWALITEIT } from '../../../quantities/wapening.js'; // Pootrichting: {boven|onder} × {links|rechts hellend}. De pijltjes tonen de // stand van de poot t.o.v. de reekslijn. @@ -64,6 +65,30 @@ export default function StavenreeksSection() { />
+ {/* Staafnummer en staalkwaliteit voor de buigstaat. Leeg nummer = + automatisch volgnummer; zonder staaflengte telt de reeks niet mee. */} +
+ + updateAnnotProp('srBarMark', e.target.value)} + /> +
+ +
+ + +
+
diff --git a/open-pdf-studio/js/solid/data/ifcCategoryMap.js b/open-pdf-studio/js/solid/data/ifcCategoryMap.js index 1bcdf9d1d..b58d985eb 100644 --- a/open-pdf-studio/js/solid/data/ifcCategoryMap.js +++ b/open-pdf-studio/js/solid/data/ifcCategoryMap.js @@ -108,6 +108,13 @@ const PARAMETRIC_ID_DEFAULT = { 'wapeningskorf': 'IfcReinforcingBar', 'wapeningsstaaf': 'IfcReinforcingBar', 'netwapening': 'IfcReinforcingBar', + // Constructieve tekengereedschappen (#312). + 'wapeningsdetail': 'IfcReinforcingBar', + 'staaf-plattegrond': 'IfcReinforcingBar', + 'wapeningsnet': 'IfcReinforcingMesh', + 'vloer-vorstrand': 'IfcReinforcingBar', + 'kopplaat': 'IfcPlate', + 'voetplaat': 'IfcPlate', 'sondering': 'IfcAnnotation', 'paalpuntniveau': 'IfcAnnotation', 'overspanningspijl-vloer': 'IfcAnnotation', diff --git a/open-pdf-studio/js/solid/data/nlSymbolLibrary.js b/open-pdf-studio/js/solid/data/nlSymbolLibrary.js index 063bac5c6..f59c0b9e3 100644 --- a/open-pdf-studio/js/solid/data/nlSymbolLibrary.js +++ b/open-pdf-studio/js/solid/data/nlSymbolLibrary.js @@ -57,6 +57,13 @@ const stavenreeksPreview = ``; // Bout: zeskantkop + ring + schacht met schroefdraad-streepjes. +// Constructieve tekengereedschappen (#312). +const wapeningsdetailPreview = `112Ø10-1502000300`; +const wapeningsnetPreview = `Ø8-150onder`; +const staafPlattegrondPreview = `1Ø10-150`; +const vloerVorstrandPreview = ``; +const kopplaatPreview = ``; +const voetplaatPreview = ``; const boutPreview = ``; // Wapeningskorf: betonomtrek met beugel (dubbele lijn + haak rechtsboven), // gevulde staafpunten boven/onder/zij en een label met het wapeningsteken — @@ -137,6 +144,13 @@ export const NL_CATEGORIES = [ { id: 'param-wapeningskorf', name: 'Wapeningskorf (doorsnede)', parametricId: 'wapeningskorf', svg: wapeningskorfPreview }, { id: 'param-wapeningsstaaf', name: 'Wapeningsstaaf', parametricId: 'wapeningsstaaf', svg: wapeningsstaafPreview }, { id: 'param-netwapening', name: 'Netwapening', parametricId: 'netwapening', svg: netwapeningPreview }, + // Constructieve tekengereedschappen (#312): wapening en staalverbindingen. + { id: 'param-wapeningsdetail', name: 'Wapeningsdetail (buigvorm)', parametricId: 'wapeningsdetail', svg: wapeningsdetailPreview }, + { id: 'param-staaf-plattegrond', name: 'Staaf in plattegrond', parametricId: 'staaf-plattegrond', svg: staafPlattegrondPreview }, + { id: 'param-wapeningsnet', name: 'Wapeningsnet (plattegrond)', parametricId: 'wapeningsnet', svg: wapeningsnetPreview }, + { id: 'param-vloer-vorstrand', name: 'Vloer met vorstrand (wapening)', parametricId: 'vloer-vorstrand', svg: vloerVorstrandPreview }, + { id: 'param-kopplaat', name: 'Kopplaat bij ligger', parametricId: 'kopplaat', svg: kopplaatPreview }, + { id: 'param-voetplaat', name: 'Voetplaat bij staalprofiel', parametricId: 'voetplaat', svg: voetplaatPreview }, { id: 'param-sondering', name: 'Sondering', parametricId: 'sondering', svg: sonderingPreview }, { id: 'param-paalpuntniveau', name: 'Paalpuntniveau', parametricId: 'paalpuntniveau', svg: paalpuntniveauPreview }, { id: 'param-overspanningspijl-vloer', name: 'Overspanningspijl vloer', parametricId: 'overspanningspijl-vloer', svg: overspanningspijlPreview }, diff --git a/open-pdf-studio/js/solid/stores/propertiesStore.js b/open-pdf-studio/js/solid/stores/propertiesStore.js index 0ea98ea31..60ced5123 100644 --- a/open-pdf-studio/js/solid/stores/propertiesStore.js +++ b/open-pdf-studio/js/solid/stores/propertiesStore.js @@ -171,6 +171,8 @@ const [annotProps, setAnnotProps] = createStore({ srFontSize: STAVENREEKS_DEFAULTS.fontSize, srDiameter: 12, srBarLengthMm: 0, + srBarMark: '', + srSteelGrade: '', srLegDir: 'down-left', srLegLength: 24, srLabelSide: 'end', @@ -537,6 +539,8 @@ export function storeShowProperties(annotation) { srCount: annotation.count ?? 3, srDiameter: annotation.diameter ?? 12, srBarLengthMm: annotation.barLengthMm ?? 0, + srBarMark: annotation.barMark ?? '', + srSteelGrade: annotation.steelGrade ?? '', srLegDir: annotation.legDir || 'down-left', srLegLength: annotation.legLength ?? STAVENREEKS_DEFAULTS.legLength, srLineTail: annotation.lineTail ?? STAVENREEKS_DEFAULTS.lineTail, @@ -1257,6 +1261,8 @@ function applyPropToAnnotation(ann, key, value) { case 'srCount': ann.count = Math.max(1, Math.min(999, leesGeheelGetal(value) || 1)); break; case 'srDiameter': ann.diameter = leesGetalBegin(value) || 12; break; case 'srBarLengthMm': ann.barLengthMm = Math.max(0, leesGetalBegin(value) || 0); break; + case 'srBarMark': ann.barMark = String(value ?? '').trim(); break; + case 'srSteelGrade': ann.steelGrade = String(value ?? '').trim().toUpperCase(); break; case 'srLegDir': ann.legDir = value; break; case 'srLegLength': ann.legLength = Math.max(1, leesGetalBegin(value) || STAVENREEKS_DEFAULTS.legLength); break; case 'srLineTail': ann.lineTail = _clampLineTail(value); break; @@ -1770,6 +1776,14 @@ function applyAnnotProp(key, value) { currentAnnotation.barLengthMm = Math.max(0, leesGetalBegin(value) || 0); break; } + case 'srBarMark': { + currentAnnotation.barMark = String(value ?? '').trim(); + break; + } + case 'srSteelGrade': { + currentAnnotation.steelGrade = String(value ?? '').trim().toUpperCase(); + break; + } case 'srLegDir': { currentAnnotation.legDir = String(value || 'down-left'); break; diff --git a/open-pdf-studio/js/solid/stores/quantitiesStore.js b/open-pdf-studio/js/solid/stores/quantitiesStore.js index 9f654b05f..6c65112c4 100644 --- a/open-pdf-studio/js/solid/stores/quantitiesStore.js +++ b/open-pdf-studio/js/solid/stores/quantitiesStore.js @@ -10,6 +10,8 @@ import '../../quantities/label-i18n.js'; import { getMeasureScale } from '../../annotations/measurement.js'; import { metSchaalBronnen } from '../../annotations/schaal-bronnen.js'; import { countTallies } from './countStore.js'; +import { wapeningsElementen } from '../../quantities/wapening.js'; +import { getTemplate } from '../../symbols/registry.js'; import { scheduleVisible, setScheduleVisible, toggleSchedule } from './scheduleStore.js'; // Lijnvormige annotatietypes zónder eigen measureValue: hun lengte moet uit de @@ -97,7 +99,10 @@ function collectElements() { return a; })); const bi = selectedCategories().includes('text-built-in') ? builtInText() : []; - const elements = [...anns, ...bi]; + // Wapening: buigstaat-regels uit stavenreeksen en wapeningssymbolen. + const rebar = selectedCategories().includes('reinforcement') + ? wapeningsElementen(doc?.annotations || [], getTemplate) : []; + const elements = [...anns, ...bi, ...rebar]; // De Locatie-kolommen kosten per element een zone-opzoeking: alleen als de // staat ze toont, erop filtert of erop sorteert. if (!scheduleUsesLocation({ fields: scheduledFields(), filters: filters(), sort: sortLevels() })) return elements; diff --git a/open-pdf-studio/js/solid/stores/schedulesStore.js b/open-pdf-studio/js/solid/stores/schedulesStore.js index 072bd3835..556d1fc3f 100644 --- a/open-pdf-studio/js/solid/stores/schedulesStore.js +++ b/open-pdf-studio/js/solid/stores/schedulesStore.js @@ -11,6 +11,8 @@ import '../../quantities/label-i18n.js'; import { getMeasureScale } from '../../annotations/measurement.js'; import { metSchaalBronnen } from '../../annotations/schaal-bronnen.js'; import { countTallies } from './countStore.js'; +import { wapeningsElementen } from '../../quantities/wapening.js'; +import { getTemplate } from '../../symbols/registry.js'; import { STANDARD_SCHEDULE_TEMPLATES, getTemplateById } from '../../quantities/schedule-templates.js'; // Lijnvormige types zónder eigen measureValue: lengte moet uit geometrie + @@ -42,14 +44,17 @@ function countCatName(categoryId) { } /** Alle annotaties van het actieve document, verrijkt zoals de engine verwacht. */ -function collectElements() { +function collectElements(categories = []) { const doc = getActiveDocument(); // Eén doorloop: de schaalbronnen één keer verzamelen, niet per lijn (#491). - return metSchaalBronnen(() => (doc?.annotations || []).map(a => { + const anns = metSchaalBronnen(() => (doc?.annotations || []).map(a => { if (a.type === 'count') return { ...a, __countCatName: countCatName(a.categoryId) }; if (LENGTH_TYPES.has(a.type) && typeof a.measureValue !== 'number') return withLengthScale(a); return a; })); + // Wapening (buigstaat/wapeningsstaat): alleen als de staat erom vraagt. + if (!(categories || []).includes('reinforcement')) return anns; + return [...anns, ...wapeningsElementen(doc?.annotations || [], getTemplate)]; } // --- Persistente lijst van schedules --- @@ -127,7 +132,7 @@ export function getScheduleById(id) { /** Bouw het (reactieve) engine-resultaat voor een schedule tegen het actieve doc. */ export function buildResultForSchedule(schedule) { if (!schedule) return null; - return buildSchedule(collectElements(), schedule.config); + return buildSchedule(collectElements(schedule.config?.categories), schedule.config); } export { schedules, setSchedules, STANDARD_SCHEDULE_TEMPLATES }; diff --git a/open-pdf-studio/js/symbols/registry.js b/open-pdf-studio/js/symbols/registry.js index 7795b3d71..2eae6bffd 100644 --- a/open-pdf-studio/js/symbols/registry.js +++ b/open-pdf-studio/js/symbols/registry.js @@ -22,6 +22,12 @@ import { boutTemplate } from './templates/bout.js'; import { wapeningskorfTemplate } from './templates/wapeningskorf.js'; import { wapeningsstaafTemplate, netwapeningTemplate } from './templates/wapening-lijn.js'; import { sonderingTemplate } from './templates/sondering.js'; +// Constructieve tekengereedschappen (#312): wapening en staalverbindingen. +import { wapeningsdetailTemplate } from './templates/wapeningsdetail.js'; +import { wapeningsnetTemplate } from './templates/wapeningsnet.js'; +import { staafPlattegrondTemplate } from './templates/staaf-plattegrond.js'; +import { vloerVorstrandTemplate } from './templates/vloer-vorstrand.js'; +import { kopplaatTemplate, voetplaatTemplate } from './templates/staalverbindingen.js'; import { paalpuntniveauTemplate } from './templates/paalpuntniveau.js'; import { overspanningspijlVloerTemplate } from './templates/overspanningspijl-vloer.js'; import { stenenrijTemplate } from './templates/stenenrij.js'; @@ -67,6 +73,12 @@ register(boutTemplate); register(wapeningskorfTemplate); register(wapeningsstaafTemplate); register(netwapeningTemplate); +register(wapeningsdetailTemplate); +register(wapeningsnetTemplate); +register(staafPlattegrondTemplate); +register(vloerVorstrandTemplate); +register(kopplaatTemplate); +register(voetplaatTemplate); register(sonderingTemplate); register(paalpuntniveauTemplate); register(overspanningspijlVloerTemplate); diff --git a/open-pdf-studio/js/symbols/templates/binnen-bbox.testhulp.mjs b/open-pdf-studio/js/symbols/templates/binnen-bbox.testhulp.mjs new file mode 100644 index 000000000..4b0aac5af --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/binnen-bbox.testhulp.mjs @@ -0,0 +1,34 @@ +// Testhulp: controleert dat alle draw-commands van een parametrisch symbool +// binnen de bbox vallen (de PDF-appearance is precies de annotatie-rect, wat +// erbuiten valt verdwijnt in andere PDF-lezers) en eindige getallen hebben. +// +// Geen *.test.mjs: dit bestand bevat zelf geen tests. + +import assert from 'node:assert/strict'; + +export function puntenVan(c) { + const p = []; + if (c.kind === 'line') p.push([c.x1, c.y1], [c.x2, c.y2]); + if (c.kind === 'circle') p.push([c.cx - c.r, c.cy - c.r], [c.cx + c.r, c.cy + c.r]); + if (c.kind === 'text') p.push([c.x, c.y]); + for (const q of c.points || []) p.push([q.x, q.y]); + for (const loop of c.loops || []) for (const q of loop) p.push([q.x, q.y]); + return p; +} + +export function assertBinnenBbox(cmds, bbox, naam = 'symbool') { + assert.ok(Array.isArray(cmds) && cmds.length > 0, `${naam}: geen tekenopdrachten`); + const tol = 0.5; + for (const c of cmds) { + for (const [x, y] of puntenVan(c)) { + assert.ok(Number.isFinite(x) && Number.isFinite(y), `${naam}: ${c.kind}/${c.role} heeft geen eindig punt`); + assert.ok(x >= bbox.x - tol && x <= bbox.x + bbox.width + tol, `${naam}: ${c.kind}/${c.role} x=${x} buiten de bbox`); + assert.ok(y >= bbox.y - tol && y <= bbox.y + bbox.height + tol, `${naam}: ${c.kind}/${c.role} y=${y} buiten de bbox`); + } + } +} + +/** Tekst van alle text-commands achter elkaar (diametertekens als 'Ø'). */ +export function teksten(cmds) { + return cmds.filter((c) => c.kind === 'text').map((c) => c.text); +} diff --git a/open-pdf-studio/js/symbols/templates/bout.js b/open-pdf-studio/js/symbols/templates/bout.js index 15eebd3e2..3aacab5d2 100644 --- a/open-pdf-studio/js/symbols/templates/bout.js +++ b/open-pdf-studio/js/symbols/templates/bout.js @@ -26,6 +26,9 @@ const _MATEN = { 'M24': { d: 24, s: 36, k: 15, m: 19 }, }; +// Ook gebruikt door de staalverbindingen (kopplaat, voetplaat). +export const BOUT_MATEN = _MATEN; + const _snapPoints = (params, bbox) => { const { x, y, width: w, height: h } = bbox; return [ diff --git a/open-pdf-studio/js/symbols/templates/staaf-plattegrond.js b/open-pdf-studio/js/symbols/templates/staaf-plattegrond.js new file mode 100644 index 000000000..5032ada86 --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/staaf-plattegrond.js @@ -0,0 +1,190 @@ +// Parametrische STAAF IN PLATTEGROND — Nederlandse tekenwijze voor een +// staafreeks in een vloer- of wandplattegrond: één representatieve staaf op +// ware lengte (dikke lijn, met eventueel omgezette haken), een verdeellijn +// loodrecht erop over de verdeelbreedte met schuine eindstreepjes, het +// staafnummer in een cirkel en de notatie "12Ø10-150 lg=4580". +// +// Het aantal volgt uit verdeelbreedte / h.o.h. + 1 (of een vast aantal); +// de vorm uit de haken: geen → 00, één → 11, beide → 21. Lengte en aantal +// komen uit quantities/wapening.js, net als in de buigstaat. + +import { + STAALKWALITEITEN, STANDAARD_STAALKWALITEIT, aantalOverBreedte, staafLengte, staafNotatie, +} from '../../quantities/wapening.js'; +import { + getal, geheelGetal, pasIn, tekstBreedte, tekstMetDiameter, staafnummerBol, + staafnummerStraal, bboxSnapPunten, +} from './wapening-tekst.js'; + +const HAKEN = ['geen', 'begin', 'eind', 'beide']; + +export function staafPlattegrondParams(params = {}) { + const p = params || {}; + const kw = String(p.staalkwaliteit || STANDAARD_STAALKWALITEIT).toUpperCase(); + return { + staafnummer: String(p.staafnummer ?? '1').trim(), + diameter: getal(p.diameter, 10, 1), + afstand: getal(p.afstand, 150, 1), + verdeelbreedte: getal(p.verdeelbreedte, 1800, 0), + aantal: geheelGetal(p.aantal, 0, 0), + lengte: getal(p.lengte, 4000, 50), + haken: HAKEN.includes(p.haken) ? p.haken : 'geen', + haaklengte: getal(p.haaklengte, 300, 0), + verdeelPositie: Math.max(0, Math.min(100, getal(p.verdeelPositie, 30))), + labelZijde: p.labelZijde === 'onder' ? 'onder' : 'boven', + staalkwaliteit: STAALKWALITEITEN.includes(kw) ? kw : STANDAARD_STAALKWALITEIT, + }; +} + +/** Vormcode en buitenmaten volgens de haken. */ +export function staafPlattegrondVorm(params) { + const p = staafPlattegrondParams(params); + switch (p.haken) { + case 'begin': + case 'eind': return { vormcode: '11', seg: { a: p.lengte, b: p.haaklengte } }; + case 'beide': return { vormcode: '21', seg: { a: p.haaklengte, b: p.lengte, c: p.haaklengte } }; + default: return { vormcode: '00', seg: { a: p.lengte } }; + } +} + +/** Aantal staven: vast aantal, anders verdeelbreedte / h.o.h. + 1. */ +export function staafPlattegrondAantal(params) { + const p = staafPlattegrondParams(params); + return p.aantal > 0 ? p.aantal : aantalOverBreedte(p.verdeelbreedte, p.afstand); +} + +export function staafPlattegrondLengte(params) { + const p = staafPlattegrondParams(params); + const v = staafPlattegrondVorm(p); + return staafLengte(v.vormcode, v.seg, p.diameter); +} + +export function staafPlattegrondLabel(params) { + const p = staafPlattegrondParams(params); + const n = staafPlattegrondAantal(p); + return `${staafNotatie({ aantal: n, diameter: p.diameter, afstand: p.afstand })} lg=${staafPlattegrondLengte(p)}`; +} + +/** Opmaak in lokale mm (y omlaag). */ +export function staafPlattegrondLayout(params) { + const p = staafPlattegrondParams(params); + const W = p.lengte; + const H = Math.max(p.verdeelbreedte, 300); + const label = staafPlattegrondLabel(p); + const perFont = tekstBreedte(label, 1) + (p.staafnummer ? 2 * staafnummerStraal(p.staafnummer, 1) + 0.6 : 0); + const font = Math.max(1, Math.min(H * 0.1, (W * 0.6) / perFont, 250)); + const staafY = H / 2; + // De schuine eindstreepjes van de verdeellijn moeten binnen de staaf blijven. + const verdeelX = Math.max(font * 0.5, Math.min(W - font * 0.5, W * p.verdeelPositie / 100)); + const labelW = perFont * font; + // Label naast de verdeellijn, binnen de staaflengte gehouden. + let labelX = verdeelX + font * 0.6; + if (labelX + labelW > W) labelX = Math.max(0, verdeelX - font * 0.6 - labelW); + const labelY = p.labelZijde === 'onder' ? staafY + font * 1.1 : staafY - font * 1.1; + const haak = Math.min(p.haaklengte, H * 0.25); + return { + p, label, font, W, H, staafY, verdeelX, labelX, labelY, labelW, haak, + footprint: { width: W, height: H }, + }; +} + +function rebarItems(params) { + const p = staafPlattegrondParams(params); + const v = staafPlattegrondVorm(p); + return [{ + staafnummer: p.staafnummer, + diameter: p.diameter, + aantal: staafPlattegrondAantal(p), + lengteMm: staafPlattegrondLengte(p), + vormcode: v.vormcode, + staalkwaliteit: p.staalkwaliteit, + afstand: p.afstand, + }]; +} + +export const staafPlattegrondTemplate = { + id: 'staaf-plattegrond', + name: 'Staaf in plattegrond', + nameEn: 'Bar in plan', + category: 'NL Constructie', + defaultSize: { width: 320, height: 144 }, + fixedSize: true, + zoektermen: ['staaf', 'staafreeks', 'verdeellijn', 'vloerwapening', 'bar in plan'], + params: [ + { key: 'staafnummer', label: 'Staafnummer', labelEn: 'Bar mark', type: 'string', default: '1' }, + { key: 'diameter', label: 'Diameter Ø (mm)', labelEn: 'Diameter Ø (mm)', type: 'number', default: 10, min: 4, step: 2 }, + { key: 'afstand', label: 'H.o.h.-afstand (mm)', labelEn: 'Spacing (mm)', type: 'number', default: 150, min: 10, step: 25 }, + { key: 'verdeelbreedte', label: 'Verdeelbreedte (mm)', labelEn: 'Distribution width (mm)', type: 'number', default: 1800, min: 0, step: 50 }, + { key: 'aantal', label: 'Aantal (0 = uit verdeling)', labelEn: 'Number (0 = from distribution)', type: 'number', default: 0, min: 0, step: 1 }, + { key: 'lengte', label: 'Staaflengte recht (mm)', labelEn: 'Straight bar length (mm)', type: 'number', default: 4000, min: 50, step: 50 }, + { + key: 'haken', label: 'Omgezette einden', labelEn: 'Bent ends', type: 'enum', default: 'geen', + options: [ + { value: 'geen', label: 'Geen (recht)' }, + { value: 'begin', label: 'Begin' }, + { value: 'eind', label: 'Eind' }, + { value: 'beide', label: 'Beide' }, + ], + }, + { key: 'haaklengte', label: 'Lengte omzetting (mm)', labelEn: 'Bent leg length (mm)', type: 'number', default: 300, min: 0, step: 10 }, + { key: 'verdeelPositie', label: 'Positie verdeellijn (%)', labelEn: 'Distribution line position (%)', type: 'number', default: 30, min: 0, max: 100, step: 5 }, + { + key: 'labelZijde', label: 'Labelzijde', labelEn: 'Label side', type: 'enum', default: 'boven', + options: [{ value: 'boven', label: 'Boven' }, { value: 'onder', label: 'Onder' }], + }, + { + key: 'staalkwaliteit', label: 'Staalkwaliteit', labelEn: 'Steel grade', type: 'enum', + default: STANDAARD_STAALKWALITEIT, options: STAALKWALITEITEN, + }, + ], + layout: staafPlattegrondLayout, + rebarItems, + realSizeMm(params) { + const L = staafPlattegrondLayout(params); + return { width: L.W, height: L.H }; + }, + snapPoints(params, bbox) { + const L = staafPlattegrondLayout(params); + const { X, Y } = pasIn(L.W, L.H, bbox); + return [ + { kind: 'endpoint', x: X(0), y: Y(L.staafY) }, + { kind: 'midpoint', x: X(L.W / 2), y: Y(L.staafY) }, + { kind: 'endpoint', x: X(L.W), y: Y(L.staafY) }, + { kind: 'endpoint', x: X(L.verdeelX), y: Y(0) }, + { kind: 'endpoint', x: X(L.verdeelX), y: Y(L.H) }, + ...bboxSnapPunten(params, bbox).slice(1), + ]; + }, + render(params, bbox) { + const L = staafPlattegrondLayout(params); + const { p } = L; + const { S, X, Y } = pasIn(L.W, L.H, bbox); + const f = L.font * S; + const cmds = []; + const staafLw = Math.max(0.8, Math.min(3, f * 0.16)); + // Staaf met omgezette einden (korte haak naar beneden in plattegrond). + const punten = []; + if (p.haken === 'begin' || p.haken === 'beide') punten.push({ x: X(0), y: Y(L.staafY + L.haak) }); + punten.push({ x: X(0), y: Y(L.staafY) }, { x: X(L.W), y: Y(L.staafY) }); + if (p.haken === 'eind' || p.haken === 'beide') punten.push({ x: X(L.W), y: Y(L.staafY + L.haak) }); + cmds.push({ kind: 'polyline', role: 'staaf', lineWidth: staafLw, points: punten }); + // Verdeellijn met schuine eindstreepjes (45°). + const vx = X(L.verdeelX); + const t = f * 0.45; + cmds.push({ kind: 'line', x1: vx, y1: Y(0), x2: vx, y2: Y(L.H), role: 'verdeellijn' }); + for (const y of [Y(0) + t, Y(L.H) - t]) { + cmds.push({ kind: 'line', x1: vx - t, y1: y + t, x2: vx + t, y2: y - t, role: 'verdeellijn' }); + } + // Label: staafnummer + notatie. + let x = X(L.labelX); + const ly = Y(L.labelY); + if (p.staafnummer) { + const r = staafnummerStraal(p.staafnummer, f); + cmds.push(...staafnummerBol(p.staafnummer, x + r, ly, f)); + x += 2 * r + f * 0.6; + } + const w = tekstBreedte(L.label, f); + cmds.push(...tekstMetDiameter(L.label, x + w / 2, ly, f, { role: 'label' })); + return cmds; + }, +}; diff --git a/open-pdf-studio/js/symbols/templates/staaf-plattegrond.test.mjs b/open-pdf-studio/js/symbols/templates/staaf-plattegrond.test.mjs new file mode 100644 index 000000000..0a1e1888c --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/staaf-plattegrond.test.mjs @@ -0,0 +1,65 @@ +// Staaf in plattegrond (#312): aantal uit de verdeling, vorm uit de haken, +// notatie en tekening. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + staafPlattegrondTemplate as tpl, staafPlattegrondAantal, staafPlattegrondVorm, + staafPlattegrondLengte, staafPlattegrondLabel, +} from './staaf-plattegrond.js'; +import { defaultParams, getTemplate } from '../registry.js'; +import { staafLengte } from '../../quantities/wapening.js'; +import { assertBinnenBbox, teksten } from './binnen-bbox.testhulp.mjs'; + +test('geregistreerd, ware maat = staaflengte × verdeelbreedte', () => { + assert.equal(getTemplate('staaf-plattegrond'), tpl); + assert.deepEqual(tpl.realSizeMm(defaultParams(tpl)), { width: 4000, height: 1800 }); + assert.deepEqual(tpl.realSizeMm({ verdeelbreedte: 0 }), { width: 4000, height: 300 }); +}); + +test('aantal uit verdeelbreedte / h.o.h. + 1, of vast', () => { + assert.equal(staafPlattegrondAantal({ verdeelbreedte: 1800, afstand: 150 }), 13); + assert.equal(staafPlattegrondAantal({ verdeelbreedte: 1800, afstand: 200 }), 10); + assert.equal(staafPlattegrondAantal({ verdeelbreedte: 1800, afstand: 150, aantal: 7 }), 7); +}); + +test('vormcode en lengte volgen de omgezette einden', () => { + assert.equal(staafPlattegrondVorm({ haken: 'geen' }).vormcode, '00'); + assert.equal(staafPlattegrondVorm({ haken: 'begin' }).vormcode, '11'); + assert.equal(staafPlattegrondVorm({ haken: 'eind' }).vormcode, '11'); + assert.equal(staafPlattegrondVorm({ haken: 'beide' }).vormcode, '21'); + assert.equal(staafPlattegrondLengte({ lengte: 4000 }), 4000); + assert.equal( + staafPlattegrondLengte({ lengte: 4000, haken: 'beide', haaklengte: 300, diameter: 10 }), + staafLengte('21', { a: 300, b: 4000, c: 300 }, 10), + ); +}); + +test('notatie "13Ø10-150 lg=4000"', () => { + assert.equal(staafPlattegrondLabel(defaultParams(tpl)), '13Ø10-150 lg=4000'); +}); + +test('render: staaf, verdeellijn met schuine streepjes, label binnen de bbox', () => { + const bbox = { x: 5, y: 5, width: 320, height: 144 }; + for (const haken of ['geen', 'begin', 'eind', 'beide']) { + for (const labelZijde of ['boven', 'onder']) { + for (const verdeelPositie of [0, 30, 100]) { + const cmds = tpl.render({ ...defaultParams(tpl), haken, labelZijde, verdeelPositie }, bbox); + assertBinnenBbox(cmds, bbox, `${haken}/${labelZijde}/${verdeelPositie}`); + const staaf = cmds.find((c) => c.role === 'staaf'); + const verwacht = 2 + (haken === 'beide' ? 2 : haken === 'geen' ? 0 : 1); + assert.equal(staaf.points.length, verwacht); + assert.equal(cmds.filter((c) => c.role === 'verdeellijn').length, 3); + } + } + } + const t = teksten(tpl.render(defaultParams(tpl), bbox)); + assert.ok(t.includes('1') && t.some((s) => s.includes('lg=4000'))); +}); + +test('rebarItems', () => { + assert.deepEqual(tpl.rebarItems({ ...defaultParams(tpl), haken: 'eind', haaklengte: 250 }), [{ + staafnummer: '1', diameter: 10, aantal: 13, lengteMm: staafLengte('11', { a: 4000, b: 250 }, 10), + vormcode: '11', staalkwaliteit: 'B500B', afstand: 150, + }]); +}); diff --git a/open-pdf-studio/js/symbols/templates/staalprofiel.js b/open-pdf-studio/js/symbols/templates/staalprofiel.js index 08a1a2033..a56e5aa3f 100644 --- a/open-pdf-studio/js/symbols/templates/staalprofiel.js +++ b/open-pdf-studio/js/symbols/templates/staalprofiel.js @@ -579,6 +579,10 @@ export const kokerTemplate = _kokerTemplate(); export const hoeklijnTemplate = _hoeklijnTemplate(); export { _hoeklijnPts as hoeklijnPts, HOEKLIJN as HOEKLIJN_TABEL, KOKER as KOKER_TABEL }; +// I-/H-profielen ([maat, h, b, tw, tf, r] in mm) voor de staalverbindingen +// (kopplaat bij ligger, voetplaat bij kolom — templates/staalverbindingen.js). +export const I_PROFIEL_TABEL = [...HEA, ...HEB, ...IPE]; + // Geometry/behaviour helpers, reused by the catalog-driven steel templates // (js/symbols/steel-catalog.js) so downloaded country catalogs render and // behave EXACTLY like the built-in NL profiles. Pure re-exports — the NL diff --git a/open-pdf-studio/js/symbols/templates/staalverbindingen.js b/open-pdf-studio/js/symbols/templates/staalverbindingen.js new file mode 100644 index 000000000..3c60b015b --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/staalverbindingen.js @@ -0,0 +1,486 @@ +// Parametrische STAALVERBINDINGEN op ware maat: +// * KOPPLAAT BIJ LIGGER — zijaanzicht van het liggereinde met de kopplaat, +// bouten en hoeklassen, of het kopaanzicht van de plaat met het profiel en +// het boutpatroon; +// * VOETPLAAT BIJ STAALPROFIEL — bovenaanzicht van de voetplaat met de +// kolomdoorsnede en de ankers, of het zijaanzicht met ondersabeling, +// funderingsbovenkant en ankerlengte. +// +// Profielmaten komen uit templates/staalprofiel.js (HEA/HEB/IPE), boutmaten +// uit templates/bout.js. Boutgaten zijn normale ronde gaten volgens +// NEN-EN 1090-2 tabel 11: d0 = d + 1 mm (M12, M14), d + 2 mm (M16 t/m M24) of +// d + 3 mm (M27 en groter). Ankergaten in de voetplaat zijn ruim vanwege de +// stelruimte: standaard d + 6 mm (ANKERGAT_SPELING), per symbool in te +// stellen met de parameter ankerGat. De automatische rand- en tussenafstanden +// en de controle volgen de minima uit NEN-EN 1993-1-8 tabel 3.3 +// (e1, e2 ≥ 1,2·d0; p1 ≥ 2,2·d0; p2 ≥ 2,4·d0), met d0 de werkelijke gatmaat. + +import { I_PROFIEL_TABEL, iSectionPts } from './staalprofiel.js'; +import { BOUT_MATEN } from './bout.js'; +import { getal, geheelGetal, pasIn, bboxSnapPunten, rechthoek } from './wapening-tekst.js'; + +export const STAALSOORTEN = ['S235', 'S275', 'S355']; +export const BOUTKLASSEN = ['4.6', '5.6', '8.8', '10.9']; +const PROFIEL = new Map(I_PROFIEL_TABEL.map((r) => [r[0], r])); +const PROFIEL_NAMEN = I_PROFIEL_TABEL.map((r) => r[0]); +const BOUT_NAMEN = ['M12', 'M16', 'M20', 'M22', 'M24']; + +const ceil5 = (v) => Math.ceil(v / 5 - 1e-9) * 5; + +/** Gatdiameter d0 (mm) van een normaal rond gat bij een boutdiameter d (NEN-EN 1090-2 tabel 11). */ +export function gatDiameter(d) { + if (d <= 14) return d + 1; + if (d <= 24) return d + 2; + return d + 3; +} + +/** Standaard speling (mm) van een ankergat in een voetplaat: d0 = d + 6. */ +export const ANKERGAT_SPELING = 6; + +/** + * Gatdiameter d0 (mm) van een ankergat in de voetplaat bij een ankerdiameter + * d. `ankerGat` > d is een opgegeven gatmaat; anders d + ANKERGAT_SPELING. + */ +export function ankerGatDiameter(d, ankerGat = 0) { + return ankerGat > d ? ankerGat : d + ANKERGAT_SPELING; +} + +function profiel(naam, standaard) { + const r = PROFIEL.get(naam) || PROFIEL.get(standaard); + const [maat, h, b, tw, tf, rr] = r; + return { maat, h, b, tw, tf, r: rr }; +} + +function bout(naam) { + const key = BOUT_MATEN[naam] ? naam : 'M20'; + return { naam: key, ...BOUT_MATEN[key] }; +} + +function dashDot(len) { + const u = Math.max(2.5, Math.min(10, len * 0.05)); + return [u, u * 0.45, u * 0.18, u * 0.45]; +} + +/** Controle van rand- en tussenafstanden (NEN-EN 1993-1-8 tabel 3.3). */ +export function controleerAfstanden({ d0, e1, e2, p1, p2 }) { + const regels = [ + ['e1', e1, 1.2 * d0], + ['e2', e2, 1.2 * d0], + ['p1', p1, 2.2 * d0], + ['p2', p2, 2.4 * d0], + ]; + return regels + .filter(([, waarde]) => Number.isFinite(waarde)) + .map(([code, waarde, minimum]) => ({ code, waarde, minimum, ok: waarde >= minimum - 1e-9 })); +} + +// ─── Kopplaat bij ligger ───────────────────────────────────────────────── + +export function kopplaatParams(params = {}) { + const p = params || {}; + return { + profiel: PROFIEL.has(p.profiel) ? p.profiel : 'IPE 300', + aanzicht: p.aanzicht === 'kop' ? 'kop' : 'zij', + plaatDikte: getal(p.plaatDikte, 15, 4), + plaatBreedte: getal(p.plaatBreedte, 0, 0), + overstek: getal(p.overstek, 0, 0), + boutMaat: BOUT_MATEN[p.boutMaat] ? p.boutMaat : 'M20', + boutRijen: Math.min(8, geheelGetal(p.boutRijen, 2, 1)), + e1: getal(p.e1, 0, 0), + e2: getal(p.e2, 0, 0), + boutklasse: BOUTKLASSEN.includes(String(p.boutklasse)) ? String(p.boutklasse) : '8.8', + staalsoort: STAALSOORTEN.includes(p.staalsoort) ? p.staalsoort : 'S235', + liggerLengte: getal(p.liggerLengte, 0, 0), + toonLabel: p.toonLabel !== false, + }; +} + +/** Plaatmaat, boutposities en de afstandscontrole (lokale mm, plaat-assen). */ +export function kopplaatGeometrie(params) { + const p = kopplaatParams(params); + const pr = profiel(p.profiel, 'IPE 300'); + const bt = bout(p.boutMaat); + const d0 = gatDiameter(bt.d); + const bp = p.plaatBreedte > 0 ? p.plaatBreedte : pr.b; + const hp = pr.h + 2 * p.overstek; + const e1 = p.e1 > 0 ? p.e1 + : (p.overstek >= 2.4 * d0 ? ceil5(p.overstek / 2) : ceil5(p.overstek + pr.tf + 1.5 * d0)); + const e2 = p.e2 > 0 ? p.e2 : ceil5(1.5 * d0); + const n = p.boutRijen; + const rijen = n === 1 ? [hp / 2] : Array.from({ length: n }, (_, i) => e1 + ((hp - 2 * e1) * i) / (n - 1)); + const p1 = n > 1 ? (hp - 2 * e1) / (n - 1) : NaN; + const p2 = bp - 2 * e2; + const kolommen = [e2, bp - e2]; + return { + p, profiel: pr, bout: bt, d0, bp, hp, e1, e2, p1, p2, rijen, kolommen, + aantalBouten: rijen.length * kolommen.length, + controle: controleerAfstanden({ d0, e1, e2, p1, p2 }), + }; +} + +export function kopplaatLabel(params) { + const g = kopplaatGeometrie(params); + return [ + `kopplaat ${Math.round(g.bp)}×${Math.round(g.hp)}×${g.p.plaatDikte} ${g.p.staalsoort}`, + `${g.aantalBouten} ${g.bout.naam}-${g.p.boutklasse} · ${g.profiel.maat}`, + ]; +} + +/** Volledige opmaak in lokale mm (y omlaag). */ +export function kopplaatLayout(params) { + const g = kopplaatGeometrie(params); + const { p, profiel: pr, bout: bt } = g; + const F = Math.max(10, Math.max(g.hp, g.bp) * 0.05); + const labels = p.toonLabel ? kopplaatLabel(p) : []; + const labelH = labels.length ? labels.length * F * 1.5 + F * 0.5 : 0; + if (p.aanzicht === 'kop') { + const m = F; + const W = Math.max(g.bp + 2 * m, ...labels.map((t) => t.length * F * 0.55 + F)); + const ox = (W - g.bp) / 2, oy = m; + return { g, F, labels, ox, oy, W, H: oy + g.hp + m + labelH, labelY: oy + g.hp + m + F * 0.6 }; + } + const Lb = p.liggerLengte > 0 ? p.liggerLengte : 1.5 * pr.h; + const rechts = 2 * bt.d; // uitstekende bout achter de plaat + const m = Math.max(F * 0.6, pr.h * 0.08); // ruimte voor de breeklijn + const breedte = Lb + p.plaatDikte + rechts; + const W = Math.max(breedte + 2 * m, ...labels.map((t) => t.length * F * 0.55 + F)); + const ox = (W - breedte) / 2, oy = m; + return { + g, F, labels, ox, oy, Lb, W, + H: oy + g.hp + m + labelH, labelY: oy + g.hp + m + F * 0.6, + }; +} + +function kopplaatZij(L, X, Y, S, cmds) { + const { g, ox, oy, Lb } = L; + const { p, profiel: pr, bout: bt } = g; + const tp = p.plaatDikte; + const ov = p.overstek; + const xp = ox + Lb; // linkerzijde van de plaat + // Ligger: flenzen + breeklijn links. + const yT = oy + ov, yB = oy + ov + pr.h; + cmds.push({ kind: 'line', x1: X(ox), y1: Y(yT), x2: X(xp), y2: Y(yT), role: 'ligger' }); + cmds.push({ kind: 'line', x1: X(ox), y1: Y(yB), x2: X(xp), y2: Y(yB), role: 'ligger' }); + cmds.push({ kind: 'line', x1: X(ox), y1: Y(yT + pr.tf), x2: X(xp), y2: Y(yT + pr.tf), role: 'ligger' }); + cmds.push({ kind: 'line', x1: X(ox), y1: Y(yB - pr.tf), x2: X(xp), y2: Y(yB - pr.tf), role: 'ligger' }); + const z = pr.h * 0.06; + cmds.push({ + kind: 'polyline', role: 'breeklijn', + points: [ + { x: X(ox), y: Y(yT) }, { x: X(ox), y: Y(yT + pr.h * 0.42) }, + { x: X(ox + z), y: Y(yT + pr.h * 0.48) }, { x: X(ox - z), y: Y(yT + pr.h * 0.54) }, + { x: X(ox), y: Y(yT + pr.h * 0.6) }, { x: X(ox), y: Y(yB) }, + ], + }); + // Kopplaat. + cmds.push(rechthoek(X(xp), Y(oy), tp * S, g.hp * S, { role: 'kopplaat' })); + // Hoeklassen aan de flenzen (gevulde driehoekjes, a ≈ 0,5·tf). + const a = Math.max(4, pr.tf * 0.7); + const las = (y, richting) => ({ + kind: 'polyline', close: true, fill: true, role: 'las', + points: [{ x: X(xp), y: Y(y) }, { x: X(xp - a), y: Y(y) }, { x: X(xp), y: Y(y + richting * a) }], + }); + cmds.push(las(yT + pr.tf, 1), las(yB - pr.tf, -1)); + if (ov > a) cmds.push(las(yT, -1), las(yB, 1)); + // Bouten per rij: moer aan de liggerzijde, schacht door de plaat, hartlijn. + for (const ry of g.rijen) { + const y = oy + ry; + const xm0 = xp - bt.m; + cmds.push(rechthoek(X(xm0), Y(y - bt.s / 2), bt.m * S, bt.s * S, { role: 'moer' })); + const x0 = xm0 - 0.8 * bt.d, x1 = xp + tp + 1.4 * bt.d; + cmds.push({ kind: 'line', x1: X(x0), y1: Y(y - bt.d / 2), x2: X(xm0), y2: Y(y - bt.d / 2), role: 'bout' }); + cmds.push({ kind: 'line', x1: X(x0), y1: Y(y + bt.d / 2), x2: X(xm0), y2: Y(y + bt.d / 2), role: 'bout' }); + cmds.push({ kind: 'line', x1: X(xp + tp), y1: Y(y - bt.d / 2), x2: X(x1), y2: Y(y - bt.d / 2), role: 'bout' }); + cmds.push({ kind: 'line', x1: X(xp + tp), y1: Y(y + bt.d / 2), x2: X(x1), y2: Y(y + bt.d / 2), role: 'bout' }); + const lijn = (x1 - x0 + bt.d) * S; + cmds.push({ kind: 'line', x1: X(x0 - bt.d * 0.5), y1: Y(y), x2: X(x1 + bt.d * 0.5), y2: Y(y), dash: dashDot(lijn), role: 'hartlijn' }); + } +} + +function kopplaatKop(L, X, Y, S, cmds) { + const { g, ox, oy } = L; + const { p, profiel: pr } = g; + cmds.push(rechthoek(X(ox), Y(oy), g.bp * S, g.hp * S, { role: 'kopplaat' })); + // Profiel (doorsnede, massief zwart) gecentreerd op de plaat. + const px = ox + (g.bp - pr.b) / 2, py = oy + p.overstek; + cmds.push({ + kind: 'rings', fill: true, role: 'profiel', + loops: [iSectionPts(pr.b, pr.h, pr.tw, pr.tf, pr.r).map((q) => ({ x: X(px + q.x), y: Y(py + q.y) }))], + }); + // Gaten met hartkruis. + const r = g.d0 / 2; + for (const ry of g.rijen) { + for (const kx of g.kolommen) { + const cx = X(ox + kx), cy = Y(oy + ry); + cmds.push({ kind: 'circle', cx, cy, r: r * S, role: 'gat' }); + const k = r * 1.6 * S; + cmds.push({ kind: 'line', x1: cx - k, y1: cy, x2: cx + k, y2: cy, role: 'hartkruis' }); + cmds.push({ kind: 'line', x1: cx, y1: cy - k, x2: cx, y2: cy + k, role: 'hartkruis' }); + } + } +} + +export const kopplaatTemplate = { + id: 'kopplaat', + name: 'Kopplaat bij ligger', + nameEn: 'End plate at beam', + category: 'NL Constructie', + defaultSize: { width: 240, height: 160 }, + fixedSize: true, + zoektermen: ['kopplaat', 'kopplaatverbinding', 'ligger', 'end plate', 'boutverbinding'], + params: [ + { key: 'profiel', label: 'Profiel', labelEn: 'Section', type: 'enum', default: 'IPE 300', options: PROFIEL_NAMEN }, + { + key: 'aanzicht', label: 'Aanzicht', labelEn: 'View', type: 'enum', default: 'zij', + options: [{ value: 'zij', label: 'Zijaanzicht ligger' }, { value: 'kop', label: 'Kopaanzicht plaat' }], + }, + { key: 'plaatDikte', label: 'Plaatdikte (mm)', labelEn: 'Plate thickness (mm)', type: 'number', default: 15, min: 4, step: 1 }, + { key: 'plaatBreedte', label: 'Plaatbreedte (mm, 0 = flensbreedte)', labelEn: 'Plate width (mm, 0 = flange width)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'overstek', label: 'Overstek boven/onder (mm)', labelEn: 'Extension top/bottom (mm)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'boutMaat', label: 'Bout', labelEn: 'Bolt', type: 'enum', default: 'M20', options: BOUT_NAMEN }, + { key: 'boutRijen', label: 'Aantal boutrijen', labelEn: 'Bolt rows', type: 'number', default: 2, min: 1, max: 8, step: 1 }, + { key: 'e1', label: 'Randafstand e1 (mm, 0 = auto)', labelEn: 'Edge distance e1 (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'e2', label: 'Randafstand e2 (mm, 0 = auto)', labelEn: 'Edge distance e2 (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'boutklasse', label: 'Boutklasse', labelEn: 'Bolt class', type: 'enum', default: '8.8', options: BOUTKLASSEN }, + { key: 'staalsoort', label: 'Staalsoort', labelEn: 'Steel grade', type: 'enum', default: 'S235', options: STAALSOORTEN }, + { key: 'liggerLengte', label: 'Getekende liggerlengte (mm, 0 = auto)', labelEn: 'Drawn beam length (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 10 }, + { key: 'toonLabel', label: 'Omschrijving tonen', labelEn: 'Show description', type: 'boolean', default: true }, + ], + layout: kopplaatLayout, + realSizeMm(params) { + const L = kopplaatLayout(params); + return { width: L.W, height: L.H }; + }, + snapPoints: bboxSnapPunten, + render(params, bbox) { + const L = kopplaatLayout(params); + const { S, X, Y } = pasIn(L.W, L.H, bbox); + const cmds = []; + if (L.g.p.aanzicht === 'kop') kopplaatKop(L, X, Y, S, cmds); + else kopplaatZij(L, X, Y, S, cmds); + L.labels.forEach((t, i) => cmds.push({ + kind: 'text', x: X(L.W / 2), y: Y(L.labelY + i * L.F * 1.5), text: t, size: L.F * S, role: 'label', + })); + return cmds; + }, +}; + +// ─── Voetplaat bij staalprofiel ────────────────────────────────────────── + +export function voetplaatParams(params = {}) { + const p = params || {}; + const n = String(p.ankerAantal ?? '4'); + return { + profiel: PROFIEL.has(p.profiel) ? p.profiel : 'HEA 200', + aanzicht: p.aanzicht === 'zij' ? 'zij' : 'boven', + plaatLengte: getal(p.plaatLengte, 0, 0), + plaatBreedte: getal(p.plaatBreedte, 0, 0), + plaatDikte: getal(p.plaatDikte, 20, 4), + ankerMaat: BOUT_MATEN[p.ankerMaat] ? p.ankerMaat : 'M20', + ankerAantal: ['2', '4', '6'].includes(n) ? Number(n) : 4, + ankerGat: getal(p.ankerGat, 0, 0), + e: getal(p.e, 0, 0), + ankerLengte: getal(p.ankerLengte, 300, 50), + ondersabeling: getal(p.ondersabeling, 30, 0), + boutklasse: BOUTKLASSEN.includes(String(p.boutklasse)) ? String(p.boutklasse) : '4.6', + staalsoort: STAALSOORTEN.includes(p.staalsoort) ? p.staalsoort : 'S235', + toonLabel: p.toonLabel !== false, + }; +} + +/** Plaatmaat en ankerposities (t.o.v. het plaatmidden, x langs het profiel-h). */ +export function voetplaatGeometrie(params) { + const p = voetplaatParams(params); + const pr = profiel(p.profiel, 'HEA 200'); + const an = bout(p.ankerMaat); + const d0 = ankerGatDiameter(an.d, p.ankerGat); + const rand = Math.max(3 * d0, 60); + const L = p.plaatLengte > 0 ? p.plaatLengte : Math.ceil((pr.h + 2 * rand) / 10) * 10; + const B = p.plaatBreedte > 0 ? p.plaatBreedte : Math.ceil((pr.b + 2 * rand) / 10) * 10; + const e = p.e > 0 ? p.e : ceil5(1.5 * d0); + const ax = L / 2 - e, ay = B / 2 - e; + let ankers; + if (p.ankerAantal === 2) ankers = [{ x: -ax, y: 0 }, { x: ax, y: 0 }]; + else if (p.ankerAantal === 6) { + ankers = [{ x: -ax, y: -ay }, { x: ax, y: -ay }, { x: -ax, y: ay }, { x: ax, y: ay }, { x: 0, y: -ay }, { x: 0, y: ay }]; + } else ankers = [{ x: -ax, y: -ay }, { x: ax, y: -ay }, { x: -ax, y: ay }, { x: ax, y: ay }]; + // Vrije ruimte tussen het hart van een anker en de flens/rand van het profiel. + const vrij = Math.min(...ankers.map((a) => { + const dx = Math.abs(a.x) - pr.h / 2; + const dy = Math.abs(a.y) - pr.b / 2; + return Math.max(dx, dy); + })); + return { + p, profiel: pr, anker: an, d0, L, B, e, ankers, + vrijeRuimte: vrij, + controle: controleerAfstanden({ + d0, e1: e, e2: e, + p1: p.ankerAantal === 2 ? NaN : (p.ankerAantal === 6 ? ax : 2 * ay), + p2: 2 * ax, + }), + }; +} + +export function voetplaatLabel(params) { + const g = voetplaatGeometrie(params); + return [ + `voetplaat ${Math.round(g.L)}×${Math.round(g.B)}×${g.p.plaatDikte} ${g.p.staalsoort}`, + `${g.ankers.length} ankers ${g.anker.naam}-${g.p.boutklasse} l=${Math.round(g.p.ankerLengte)} · ${g.profiel.maat}`, + ]; +} + +export function voetplaatLayout(params) { + const g = voetplaatGeometrie(params); + const { p, profiel: pr, anker: an } = g; + const F = Math.max(10, Math.max(g.L, g.B) * 0.06); + const labels = p.toonLabel ? voetplaatLabel(p) : []; + const labelH = labels.length ? labels.length * F * 1.5 + F * 0.5 : 0; + const tekstW = Math.max(0, ...labels.map((t) => t.length * F * 0.55 + F)); + if (p.aanzicht === 'zij') { + const uit = g.L * 0.2; // funderingslijn steekt uit + const kolomH = pr.h * 0.9; + const boven = kolomH + an.m + 0.15 * an.d + 0.6 * an.d; + const onder = p.plaatDikte + p.ondersabeling + p.ankerLengte; + const breedte = g.L + 2 * uit; + const W = Math.max(breedte + 2 * F * 0.5, tekstW); + const cx = W / 2; + const oy = F * 0.5 + boven; // bovenkant voetplaat + return { + g, F, labels, W, cx, oy, uit, kolomH, + H: oy + onder + F * 0.8 + labelH, labelY: oy + onder + F * 1.4, + }; + } + const m = Math.max(F * 0.8, Math.max(g.L, g.B) * 0.1); // ruimte voor de hartlijnen + const W = Math.max(g.L + 2 * m, tekstW); + const cx = W / 2, cy = m + g.B / 2; + return { g, F, labels, W, cx, cy, H: m + g.B + m + labelH, labelY: m + g.B + m + F * 0.6 }; +} + +function voetplaatBoven(L, X, Y, S, cmds) { + const { g, cx, cy } = L; + const pr = g.profiel; + cmds.push(rechthoek(X(cx - g.L / 2), Y(cy - g.B / 2), g.L * S, g.B * S, { role: 'voetplaat' })); + // Kolomdoorsnede: het profiel-h langs x (lijf horizontaal), massief. + const pts = iSectionPts(pr.b, pr.h, pr.tw, pr.tf, pr.r); + cmds.push({ + kind: 'rings', fill: true, role: 'profiel', + loops: [pts.map((q) => ({ x: X(cx - pr.h / 2 + q.y), y: Y(cy - pr.b / 2 + q.x) }))], + }); + const ex = Math.max(g.L, g.B) * 0.08; + cmds.push({ kind: 'line', x1: X(cx - g.L / 2 - ex), y1: Y(cy), x2: X(cx + g.L / 2 + ex), y2: Y(cy), dash: dashDot((g.L + 2 * ex) * S), role: 'hartlijn' }); + cmds.push({ kind: 'line', x1: X(cx), y1: Y(cy - g.B / 2 - ex), x2: X(cx), y2: Y(cy + g.B / 2 + ex), dash: dashDot((g.B + 2 * ex) * S), role: 'hartlijn' }); + for (const a of g.ankers) { + const ax = X(cx + a.x), ay = Y(cy + a.y); + cmds.push({ kind: 'circle', cx: ax, cy: ay, r: (g.d0 / 2) * S, role: 'gat' }); + cmds.push({ kind: 'circle', cx: ax, cy: ay, r: (1.1 * g.anker.d) * S, role: 'ring' }); + const k = g.d0 * 0.9 * S; + cmds.push({ kind: 'line', x1: ax - k, y1: ay, x2: ax + k, y2: ay, role: 'hartkruis' }); + cmds.push({ kind: 'line', x1: ax, y1: ay - k, x2: ax, y2: ay + k, role: 'hartkruis' }); + } +} + +function voetplaatZij(L, X, Y, S, cmds) { + const { g, cx, oy, uit, kolomH } = L; + const { p, profiel: pr, anker: an } = g; + const tp = p.plaatDikte, og = p.ondersabeling; + // Kolom (flenzen in aanzicht) met breeklijn boven. + const k0 = cx - pr.h / 2, k1 = cx + pr.h / 2, top = oy - kolomH; + for (const x of [k0, k0 + pr.tf, k1 - pr.tf, k1]) { + cmds.push({ kind: 'line', x1: X(x), y1: Y(top), x2: X(x), y2: Y(oy), role: 'kolom' }); + } + const z = pr.h * 0.05; + cmds.push({ + kind: 'polyline', role: 'breeklijn', + points: [ + { x: X(k0), y: Y(top) }, { x: X(cx - z * 2), y: Y(top) }, { x: X(cx - z), y: Y(top - z) }, + { x: X(cx + z), y: Y(top + z) }, { x: X(cx + z * 2), y: Y(top) }, { x: X(k1), y: Y(top) }, + ], + }); + // Voetplaat en ondersabeling. + cmds.push(rechthoek(X(cx - g.L / 2), Y(oy), g.L * S, tp * S, { role: 'voetplaat' })); + if (og > 0) cmds.push(rechthoek(X(cx - g.L / 2), Y(oy + tp), g.L * S, og * S, { role: 'ondersabeling', dash: [Math.max(2, og * S * 0.4), Math.max(1.5, og * S * 0.25)] })); + // Bovenkant fundering met arcering-streepjes. + const yf = oy + tp + og; + const fx0 = cx - g.L / 2 - uit, fx1 = cx + g.L / 2 + uit; + cmds.push({ kind: 'line', x1: X(fx0), y1: Y(yf), x2: X(fx1), y2: Y(yf), role: 'fundering' }); + const stap = (fx1 - fx0) / 14; + for (let x = fx0 + stap / 2; x < fx1; x += stap) { + cmds.push({ kind: 'line', x1: X(x), y1: Y(yf), x2: X(x - stap * 0.6), y2: Y(yf + stap * 0.6), role: 'arcering' }); + } + // Hoeklassen kolom–plaat. + const a = Math.max(4, pr.tf * 0.7); + for (const [x, r] of [[k0, -1], [k1, 1]]) { + cmds.push({ + kind: 'polyline', close: true, fill: true, role: 'las', + points: [{ x: X(x), y: Y(oy) }, { x: X(x + r * a), y: Y(oy) }, { x: X(x), y: Y(oy - a) }], + }); + } + // Ankers (unieke x-posities in dit aanzicht). + const xs = [...new Set(g.ankers.map((q) => Math.round(q.x * 1000) / 1000))]; + const ring = 0.15 * an.d; + for (const ax of xs) { + const x = cx + ax; + const yTop = oy - ring - an.m - 0.6 * an.d; + const yEind = yf + p.ankerLengte; + // Moer + onderlegring op de plaat. + cmds.push(rechthoek(X(x - an.s / 2), Y(oy - ring - an.m), an.s * S, an.m * S, { role: 'moer' })); + cmds.push(rechthoek(X(x - 1.1 * an.d), Y(oy - ring), 2.2 * an.d * S, ring * S, { role: 'ring' })); + // Schacht: het draadeind boven de moer zichtbaar; door plaat, + // ondersabeling en fundering verborgen (gestreept). + for (const dx of [-an.d / 2, an.d / 2]) { + cmds.push({ kind: 'line', x1: X(x + dx), y1: Y(yTop), x2: X(x + dx), y2: Y(oy - ring - an.m), role: 'anker' }); + cmds.push({ kind: 'line', x1: X(x + dx), y1: Y(oy), x2: X(x + dx), y2: Y(yEind), dash: [Math.max(2, an.d * S * 0.6), Math.max(1.5, an.d * S * 0.4)], role: 'anker' }); + } + // Ankerplaatje aan het einde. + cmds.push({ kind: 'line', x1: X(x - 1.5 * an.d), y1: Y(yEind), x2: X(x + 1.5 * an.d), y2: Y(yEind), dash: [Math.max(2, an.d * S * 0.6), Math.max(1.5, an.d * S * 0.4)], role: 'ankerplaat' }); + } +} + +export const voetplaatTemplate = { + id: 'voetplaat', + name: 'Voetplaat bij staalprofiel', + nameEn: 'Base plate at steel column', + category: 'NL Constructie', + defaultSize: { width: 220, height: 200 }, + fixedSize: true, + zoektermen: ['voetplaat', 'kolomvoet', 'ankers', 'ankerbout', 'base plate', 'ondersabeling'], + params: [ + { key: 'profiel', label: 'Profiel', labelEn: 'Section', type: 'enum', default: 'HEA 200', options: PROFIEL_NAMEN }, + { + key: 'aanzicht', label: 'Aanzicht', labelEn: 'View', type: 'enum', default: 'boven', + options: [{ value: 'boven', label: 'Bovenaanzicht' }, { value: 'zij', label: 'Zijaanzicht' }], + }, + { key: 'plaatLengte', label: 'Plaatlengte (mm, 0 = auto)', labelEn: 'Plate length (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 10 }, + { key: 'plaatBreedte', label: 'Plaatbreedte (mm, 0 = auto)', labelEn: 'Plate width (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 10 }, + { key: 'plaatDikte', label: 'Plaatdikte (mm)', labelEn: 'Plate thickness (mm)', type: 'number', default: 20, min: 4, step: 1 }, + { key: 'ankerMaat', label: 'Anker', labelEn: 'Anchor', type: 'enum', default: 'M20', options: BOUT_NAMEN }, + { + key: 'ankerAantal', label: 'Aantal ankers', labelEn: 'Number of anchors', type: 'enum', default: '4', + options: [{ value: '2', label: '2 (op de lijfas)' }, { value: '4', label: '4 (hoeken)' }, { value: '6', label: '6 (hoeken + midden)' }], + }, + { key: 'ankerGat', label: 'Gatdiameter anker (mm, 0 = d + 6)', labelEn: 'Anchor hole diameter (mm, 0 = d + 6)', type: 'number', default: 0, min: 0, step: 1 }, + { key: 'e', label: 'Randafstand anker (mm, 0 = auto)', labelEn: 'Anchor edge distance (mm, 0 = auto)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'ankerLengte', label: 'Ankerlengte in fundering (mm)', labelEn: 'Anchor embedment (mm)', type: 'number', default: 300, min: 50, step: 10 }, + { key: 'ondersabeling', label: 'Ondersabeling (mm)', labelEn: 'Grout layer (mm)', type: 'number', default: 30, min: 0, step: 5 }, + { key: 'boutklasse', label: 'Ankerklasse', labelEn: 'Anchor class', type: 'enum', default: '4.6', options: BOUTKLASSEN }, + { key: 'staalsoort', label: 'Staalsoort', labelEn: 'Steel grade', type: 'enum', default: 'S235', options: STAALSOORTEN }, + { key: 'toonLabel', label: 'Omschrijving tonen', labelEn: 'Show description', type: 'boolean', default: true }, + ], + layout: voetplaatLayout, + realSizeMm(params) { + const L = voetplaatLayout(params); + return { width: L.W, height: L.H }; + }, + snapPoints: bboxSnapPunten, + render(params, bbox) { + const L = voetplaatLayout(params); + const { S, X, Y } = pasIn(L.W, L.H, bbox); + const cmds = []; + if (L.g.p.aanzicht === 'zij') voetplaatZij(L, X, Y, S, cmds); + else voetplaatBoven(L, X, Y, S, cmds); + L.labels.forEach((t, i) => cmds.push({ + kind: 'text', x: X(L.W / 2), y: Y(L.labelY + i * L.F * 1.5), text: t, size: L.F * S, role: 'label', + })); + return cmds; + }, +}; diff --git a/open-pdf-studio/js/symbols/templates/staalverbindingen.test.mjs b/open-pdf-studio/js/symbols/templates/staalverbindingen.test.mjs new file mode 100644 index 000000000..3b51ccaae --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/staalverbindingen.test.mjs @@ -0,0 +1,125 @@ +// Kopplaat bij ligger en voetplaat bij staalprofiel (#312): plaatmaat, +// bout-/ankerposities, afstandscontrole (NEN-EN 1993-1-8 tabel 3.3) en tekening. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + kopplaatTemplate, voetplaatTemplate, kopplaatGeometrie, kopplaatLabel, voetplaatGeometrie, + voetplaatLabel, gatDiameter, ankerGatDiameter, ANKERGAT_SPELING, controleerAfstanden, +} from './staalverbindingen.js'; +import { defaultParams, getTemplate } from '../registry.js'; +import { assertBinnenBbox, teksten } from './binnen-bbox.testhulp.mjs'; + +function binnen(tpl, p) { + const mm = tpl.realSizeMm(p); + assert.ok(mm.width > 0 && mm.height > 0); + const bbox = { x: 3, y: 4, width: mm.width / 3, height: mm.height / 3 }; + const cmds = tpl.render(p, bbox); + assertBinnenBbox(cmds, bbox, `${tpl.id} ${p.aanzicht}`); + return cmds; +} + +test('gatdiameter en controle van rand- en tussenafstanden', () => { + assert.equal(gatDiameter(20), 22); + assert.equal(gatDiameter(27), 30); + const c = controleerAfstanden({ d0: 22, e1: 26, e2: 27, p1: 48, p2: 52 }); + assert.deepEqual(c.map((r) => [r.code, r.ok]), [['e1', false], ['e2', true], ['p1', false], ['p2', false]]); + assert.equal(controleerAfstanden({ d0: 22, e1: 40, e2: 40, p1: NaN, p2: 60 }).length, 3, 'zonder p1 bij één rij'); +}); + +test('kopplaat: plaatmaat en boutpatroon', () => { + assert.equal(getTemplate('kopplaat'), kopplaatTemplate); + const g = kopplaatGeometrie({ profiel: 'IPE 300', boutMaat: 'M20', boutRijen: 2 }); + assert.equal(g.bp, 150); + assert.equal(g.hp, 300); + assert.equal(g.d0, 22); + assert.equal(g.e2, 35); // 1,5·d0 = 33 → 35 + assert.equal(g.e1, 45); // tf 10,7 + 33 → 45 + assert.deepEqual(g.rijen, [45, 255]); + assert.deepEqual(g.kolommen, [35, 115]); + assert.equal(g.aantalBouten, 4); + assert.ok(g.controle.every((r) => r.ok), JSON.stringify(g.controle)); + + const ver = kopplaatGeometrie({ profiel: 'HEA 200', overstek: 60, boutRijen: 4, plaatBreedte: 220 }); + assert.equal(ver.hp, 190 + 120); + assert.equal(ver.bp, 220); + assert.equal(ver.e1, 30, 'boutrij in het overstek'); + assert.equal(ver.rijen.length, 4); + assert.equal(ver.rijen[3], ver.hp - 30); + + assert.deepEqual(kopplaatLabel({ profiel: 'IPE 300' }), ['kopplaat 150×300×15 S235', '4 M20-8.8 · IPE 300']); + assert.equal(kopplaatGeometrie({ e2: 20 }).controle.find((r) => r.code === 'e2').ok, false); +}); + +test('kopplaat: zij- en kopaanzicht binnen de bbox', () => { + const zij = binnen(kopplaatTemplate, defaultParams(kopplaatTemplate)); + assert.equal(zij.filter((c) => c.role === 'moer').length, 2); + assert.ok(zij.some((c) => c.role === 'las')); + assert.ok(teksten(zij).includes('kopplaat 150×300×15 S235')); + const kop = binnen(kopplaatTemplate, { ...defaultParams(kopplaatTemplate), aanzicht: 'kop', boutRijen: 3 }); + assert.equal(kop.filter((c) => c.role === 'gat').length, 6); + assert.ok(kop.some((c) => c.kind === 'rings' && c.role === 'profiel')); + binnen(kopplaatTemplate, { ...defaultParams(kopplaatTemplate), aanzicht: 'zij', overstek: 80, profiel: 'HEB 500', toonLabel: false }); +}); + +test('voetplaat: plaatmaat en ankerposities', () => { + assert.equal(getTemplate('voetplaat'), voetplaatTemplate); + const g = voetplaatGeometrie({ profiel: 'HEA 200', ankerMaat: 'M20' }); + // Ankergat d0 = 20 + 6 = 26; rand = max(3·d0, 60) = 78 + // → L = 190 + 156 = 346 → 350, B = 200 + 156 = 356 → 360; e = 1,5·26 = 39 → 40. + assert.equal(g.d0, 26); + assert.equal(g.L, 350); + assert.equal(g.B, 360); + assert.equal(g.e, 40); + assert.equal(g.ankers.length, 4); + assert.deepEqual(g.ankers[0], { x: -(175 - 40), y: -(180 - 40) }); + assert.ok(g.vrijeRuimte > 0, 'ankers vallen buiten het profiel'); + assert.ok(g.controle.every((r) => r.ok)); + assert.equal(voetplaatGeometrie({ ankerAantal: '2' }).ankers.length, 2); + assert.equal(voetplaatGeometrie({ ankerAantal: '6' }).ankers.length, 6); + assert.equal(voetplaatGeometrie({ plaatLengte: 400, plaatBreedte: 300 }).L, 400); + assert.deepEqual(voetplaatLabel({}), ['voetplaat 350×360×20 S235', '4 ankers M20-4.6 l=300 · HEA 200']); +}); + +test('voetplaat: boven- en zijaanzicht binnen de bbox', () => { + const boven = binnen(voetplaatTemplate, defaultParams(voetplaatTemplate)); + assert.equal(boven.filter((c) => c.role === 'gat').length, 4); + const zij = binnen(voetplaatTemplate, { ...defaultParams(voetplaatTemplate), aanzicht: 'zij' }); + assert.equal(zij.filter((c) => c.role === 'moer').length, 2, 'twee ankerposities in zijaanzicht'); + assert.ok(zij.some((c) => c.role === 'ondersabeling')); + assert.ok(zij.some((c) => c.role === 'anker' && Array.isArray(c.dash)), 'ingestorte ankerlengte gestreept'); + const zes = binnen(voetplaatTemplate, { ...defaultParams(voetplaatTemplate), aanzicht: 'zij', ankerAantal: '6', ondersabeling: 0 }); + assert.equal(zes.filter((c) => c.role === 'moer').length, 3); + assert.ok(!zes.some((c) => c.role === 'ondersabeling')); +}); + +test('gatdiameter normale ronde gaten volgens NEN-EN 1090-2 tabel 11', () => { + // M12 en M14: d + 1; M16 t/m M24: d + 2; M27 en groter: d + 3. + assert.deepEqual([12, 14, 16, 20, 22, 24, 27, 30, 36].map(gatDiameter), [13, 15, 18, 22, 24, 26, 30, 33, 39]); + const g = kopplaatGeometrie({ profiel: 'IPE 300', boutMaat: 'M12', boutRijen: 2 }); + assert.equal(g.d0, 13, 'M12 in de kopplaat: d0 = 13'); + // e2 = 1,5·13 = 19,5 → 20 ≥ 1,2·13 = 15,6: de controle is niet strenger dan de norm. + assert.equal(g.e2, 20); + assert.ok(g.controle.find((r) => r.code === 'e2').ok); + const gat = binnen(kopplaatTemplate, { ...defaultParams(kopplaatTemplate), aanzicht: 'kop', boutMaat: 'M12' }) + .find((c) => c.role === 'gat'); + // binnen() tekent op 1/3 van de ware maat: straal = d0/2 · 1/3. + assert.ok(Math.abs(gat.r - 13 / 6) < 1e-6, `gatstraal ${gat.r}`); +}); + +test('voetplaat: ankergaten ruimer dan boutgaten, apart instelbaar', () => { + assert.equal(ANKERGAT_SPELING, 6); + assert.equal(ankerGatDiameter(20), 26); + assert.equal(ankerGatDiameter(20, 30), 30, 'opgegeven gatmaat'); + assert.equal(ankerGatDiameter(20, 18), 26, 'een gat kleiner dan het anker telt niet'); + const std = voetplaatGeometrie({ ankerMaat: 'M20' }); + assert.ok(std.d0 > gatDiameter(20), 'ankergat ruimer dan een normaal boutgat'); + const eigen = voetplaatGeometrie({ ankerMaat: 'M20', ankerGat: 32 }); + assert.equal(eigen.d0, 32); + assert.equal(eigen.e, 50, 'automatische randafstand volgt de ankergatmaat (1,5·32 = 48 → 50)'); + assert.equal(voetplaatTemplate.params.find((q) => q.key === 'ankerGat').default, 0); + const boven = binnen(voetplaatTemplate, { ...defaultParams(voetplaatTemplate), ankerGat: 32 }); + // binnen() tekent op 1/3 van de ware maat: straal = d0/2 · 1/3. + const gat = boven.find((c) => c.role === 'gat'); + assert.ok(Math.abs(gat.r - 16 / 3) < 1e-6, `gatstraal ${gat.r}`); +}); diff --git a/open-pdf-studio/js/symbols/templates/vloer-vorstrand.js b/open-pdf-studio/js/symbols/templates/vloer-vorstrand.js new file mode 100644 index 000000000..50a7b432d --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/vloer-vorstrand.js @@ -0,0 +1,270 @@ +// Parametrisch wapeningsdetail VLOER MET VORSTRAND — doorsnede over de rand +// van een vloer op staal of op palen, met de verdiepte vorstrand: de +// betonomtrek op ware maat (met een breeklijn waar de vloer doorloopt), de +// vloerwapening (onder- en/of bovennet: lijn met de dwarsstaven als stippen), +// de gesloten beugel en de langsstaven in de vorstrand, staafnummers met +// aanwijslijn, maatvoering en een legenda met de notatie per positie. +// +// De vorstrandwapening (langsstaven + beugels) gaat over de opgegeven +// randlengte naar de buigstaat. De netten zelf niet: die tel je met het +// gereedschap Wapeningsnet, anders zouden ze dubbel in de staat komen. + +import { + STAALKWALITEITEN, STANDAARD_STAALKWALITEIT, aantalOverBreedte, staafLengte, staafNotatie, +} from '../../quantities/wapening.js'; +import { + getal, geheelGetal, pasIn, tekstBreedte, tekstMetDiameter, staafnummerBol, + staafnummerStraal, maatlijn, stip, bboxSnapPunten, +} from './wapening-tekst.js'; + +const NET_LAGEN = ['onder', 'boven', 'beide']; + +export function vorstrandParams(params = {}) { + const p = params || {}; + const kw = String(p.staalkwaliteit || STANDAARD_STAALKWALITEIT).toUpperCase(); + const vloerdikte = getal(p.vloerdikte, 200, 50); + return { + vloerdikte, + vorstrandBreedte: getal(p.vorstrandBreedte, 400, 100), + vorstrandHoogte: Math.max(vloerdikte + 50, getal(p.vorstrandHoogte, 800, 100)), + vloerLengte: getal(p.vloerLengte, 1200, 200), + dekking: getal(p.dekking, 40, 0), + netDiameter: getal(p.netDiameter, 8, 1), + netAfstand: getal(p.netAfstand, 150, 25), + netLagen: NET_LAGEN.includes(p.netLagen) ? p.netLagen : 'beide', + randAantal: geheelGetal(p.randAantal, 4, 0), + randDiameter: getal(p.randDiameter, 12, 1), + beugelDiameter: getal(p.beugelDiameter, 8, 1), + beugelAfstand: getal(p.beugelAfstand, 200, 25), + lengte: getal(p.lengte, 10000, 0), + staafnummerStart: String(p.staafnummerStart ?? '1').trim(), + staalkwaliteit: STAALKWALITEITEN.includes(kw) ? kw : STANDAARD_STAALKWALITEIT, + naam: String(p.naam ?? 'Vloer met vorstrand'), + toonMaten: p.toonMaten !== false, + }; +} + +function nummers(start, n) { + const s = Number(start); + if (!start || !Number.isInteger(s)) return Array.from({ length: n }, () => ''); + return Array.from({ length: n }, (_, i) => String(s + i)); +} + +/** Staven van de vorstrand over de randlengte. */ +export function vorstrandStaven(params) { + const p = vorstrandParams(params); + const [nrRand, nrBeugel] = nummers(p.staafnummerStart, 2); + const netto = Math.max(0, p.lengte - 2 * p.dekking); + const beugelA = Math.max(0, p.vorstrandBreedte - 2 * p.dekking); + const beugelB = Math.max(0, p.vorstrandHoogte - 2 * p.dekking); + return { + rand: { + staafnummer: nrRand, diameter: p.randDiameter, aantal: p.randAantal, + lengteMm: Math.round(netto), vormcode: '00', staalkwaliteit: p.staalkwaliteit, + }, + beugel: { + staafnummer: nrBeugel, diameter: p.beugelDiameter, afstand: p.beugelAfstand, + aantal: netto > 0 ? aantalOverBreedte(netto, p.beugelAfstand) : 0, + lengteMm: staafLengte('51', { a: beugelA, b: beugelB }, p.beugelDiameter), + vormcode: '51', staalkwaliteit: p.staalkwaliteit, a: beugelA, b: beugelB, + }, + }; +} + +/** Volledige geometrie in lokale mm (y omlaag). Pure functie. */ +export function vorstrandLayout(params) { + const p = vorstrandParams(params); + const t = p.vloerdikte, bw = p.vorstrandBreedte, Ht = p.vorstrandHoogte; + const Lt = bw + p.vloerLengte; + const c = p.dekking; + const F = Math.max(20, Lt * 0.04); + const staven = vorstrandStaven(p); + + const legenda = []; + if (p.randAantal > 0) { + legenda.push({ nr: staven.rand.staafnummer, tekst: `${staafNotatie({ aantal: p.randAantal, diameter: p.randDiameter })} lg=${staven.rand.lengteMm}` }); + } + legenda.push({ nr: staven.beugel.staafnummer, tekst: `bgls ${staafNotatie({ diameter: p.beugelDiameter, afstand: p.beugelAfstand })} lg=${staven.beugel.lengteMm}` }); + const net = staafNotatie({ diameter: p.netDiameter, afstand: p.netAfstand }); + const lagen = p.netLagen === 'beide' ? 'onder + boven' : p.netLagen; + legenda.push({ nr: '', tekst: `net ${net}/${net} ${lagen}` }); + legenda.push({ nr: '', tekst: `${p.naam} ${p.staalkwaliteit}`.trim() }); + + const legendaW = Math.max(...legenda.map((r) => tekstBreedte(r.tekst, F) + (r.nr ? 2 * staafnummerStraal(r.nr, F) + F * 0.5 : 0))); + const mL = p.toonMaten ? F * 3.6 : F; + const mR = p.toonMaten ? F * 3.6 : F; + const mT = F; + const mB = (p.toonMaten ? F * 2.6 : F) + legenda.length * F * 1.6 + F * 0.4; + const W = Math.max(mL + Lt + mR, legendaW + 2 * F); + const ox = mL, oy = mT; + const H = oy + Ht + mB; + + // Betonomtrek met breeklijn aan de vloerzijde. + const xe = ox + Lt; + const z = t * 0.14; + const omtrek = [ + { x: ox, y: oy }, { x: xe, y: oy }, + { x: xe, y: oy + t * 0.35 }, { x: xe + z, y: oy + t * 0.45 }, + { x: xe - z, y: oy + t * 0.55 }, { x: xe, y: oy + t * 0.65 }, + { x: xe, y: oy + t }, { x: ox + bw, y: oy + t }, + { x: ox + bw, y: oy + Ht }, { x: ox, y: oy + Ht }, + ]; + + // Netten: lijn + dwarsstaven als stippen aan de binnenzijde. + const dn = p.netDiameter; + const stipR = Math.max(dn / 2, F * 0.09); + const netten = []; + const laagY = { onder: oy + t - c - dn / 2, boven: oy + c + dn / 2 }; + for (const laag of ['onder', 'boven']) { + if (p.netLagen !== 'beide' && p.netLagen !== laag) continue; + const y = laagY[laag]; + const stipY = laag === 'onder' ? y - stipR - dn / 2 : y + stipR + dn / 2; + const stippen = []; + for (let x = ox + c + p.netAfstand / 2; x < xe - stipR; x += p.netAfstand) stippen.push({ x, y: stipY }); + netten.push({ laag, x1: ox + c, x2: xe, y, stippen }); + } + + // Beugel en langsstaven in de vorstrand. + const db = p.beugelDiameter, dr = p.randDiameter; + const beugel = { x: ox + c, y: oy + c, w: Math.max(0, bw - 2 * c), h: Math.max(0, Ht - 2 * c) }; + const nBoven = Math.ceil(p.randAantal / 2), nOnder = Math.floor(p.randAantal / 2); + const spreid = (n) => { + if (n <= 0) return []; + const a = ox + c + db + dr / 2, b = ox + bw - c - db - dr / 2; + if (n === 1) return [(a + b) / 2]; + return Array.from({ length: n }, (_, i) => a + ((b - a) * i) / (n - 1)); + }; + const randR = Math.max(dr / 2, F * 0.12); + const rand = [ + ...spreid(nBoven).map((x) => ({ x, y: oy + c + db + dr / 2 })), + ...spreid(nOnder).map((x) => ({ x, y: oy + Ht - c - db - dr / 2 })), + ]; + + // Staafnummers met aanwijslijn, rechts van de vorstrand (onder de vloer). + const bolX = ox + bw + F * 2.2; + const aanwijzers = []; + if (p.randAantal > 0 && rand.length) { + const doel = rand[rand.length - 1]; + aanwijzers.push({ nr: staven.rand.staafnummer, x: bolX, y: oy + Ht - F * 1.2, doel }); + } + aanwijzers.push({ + nr: staven.beugel.staafnummer, x: bolX, y: oy + t + (Ht - t) * 0.45, + doel: { x: beugel.x + beugel.w, y: oy + t + (Ht - t) * 0.45 }, + }); + + return { + p, F, W, H, ox, oy, Lt, omtrek, netten, stipR, beugel, rand, randR, aanwijzers, + legenda, legendaY: oy + Ht + (p.toonMaten ? F * 2.6 : F) + F * 0.8, + staven, + footprint: { width: W, height: H }, + }; +} + +function rebarItems(params) { + const s = vorstrandStaven(params); + return [s.rand, s.beugel]; +} + +export const vloerVorstrandTemplate = { + id: 'vloer-vorstrand', + name: 'Vloer met vorstrand (wapening)', + nameEn: 'Slab with frost edge (reinforcement)', + category: 'NL Constructie', + defaultSize: { width: 300, height: 220 }, + fixedSize: true, + zoektermen: ['vorstrand', 'randbalk', 'vloerrand', 'begane grondvloer', 'frost edge', 'wapeningsdetail'], + params: [ + { key: 'vloerdikte', label: 'Vloerdikte (mm)', labelEn: 'Slab thickness (mm)', type: 'number', default: 200, min: 50, step: 10 }, + { key: 'vorstrandBreedte', label: 'Breedte vorstrand (mm)', labelEn: 'Frost edge width (mm)', type: 'number', default: 400, min: 100, step: 10 }, + { key: 'vorstrandHoogte', label: 'Hoogte vorstrand incl. vloer (mm)', labelEn: 'Frost edge depth incl. slab (mm)', type: 'number', default: 800, min: 100, step: 10 }, + { key: 'vloerLengte', label: 'Getekende vloerstrook (mm)', labelEn: 'Drawn slab strip (mm)', type: 'number', default: 1200, min: 200, step: 50 }, + { key: 'dekking', label: 'Dekking (mm)', labelEn: 'Cover (mm)', type: 'number', default: 40, min: 0, step: 5 }, + { key: 'netDiameter', label: 'Net Ø (mm)', labelEn: 'Mesh Ø (mm)', type: 'number', default: 8, min: 4, step: 1 }, + { key: 'netAfstand', label: 'Net h.o.h. (mm)', labelEn: 'Mesh spacing (mm)', type: 'number', default: 150, min: 25, step: 25 }, + { + key: 'netLagen', label: 'Netten', labelEn: 'Mesh layers', type: 'enum', default: 'beide', + options: [ + { value: 'beide', label: 'Onder + boven' }, + { value: 'onder', label: 'Alleen onder' }, + { value: 'boven', label: 'Alleen boven' }, + ], + }, + { key: 'randAantal', label: 'Langsstaven vorstrand', labelEn: 'Frost edge longitudinal bars', type: 'number', default: 4, min: 0, step: 1 }, + { key: 'randDiameter', label: 'Langsstaaf Ø (mm)', labelEn: 'Longitudinal bar Ø (mm)', type: 'number', default: 12, min: 4, step: 2 }, + { key: 'beugelDiameter', label: 'Beugel Ø (mm)', labelEn: 'Stirrup Ø (mm)', type: 'number', default: 8, min: 4, step: 2 }, + { key: 'beugelAfstand', label: 'Beugel h.o.h. (mm)', labelEn: 'Stirrup spacing (mm)', type: 'number', default: 200, min: 25, step: 25 }, + { key: 'lengte', label: 'Randlengte voor staat (mm)', labelEn: 'Edge length for schedule (mm)', type: 'number', default: 10000, min: 0, step: 100 }, + { key: 'staafnummerStart', label: 'Eerste staafnummer', labelEn: 'First bar mark', type: 'string', default: '1' }, + { + key: 'staalkwaliteit', label: 'Staalkwaliteit', labelEn: 'Steel grade', type: 'enum', + default: STANDAARD_STAALKWALITEIT, options: STAALKWALITEITEN, + }, + { key: 'naam', label: 'Onderschrift', labelEn: 'Caption', type: 'string', default: 'Vloer met vorstrand' }, + { key: 'toonMaten', label: 'Maatlijnen tonen', labelEn: 'Show dimensions', type: 'boolean', default: true }, + ], + layout: vorstrandLayout, + rebarItems, + realSizeMm(params) { + const L = vorstrandLayout(params); + return { width: L.W, height: L.H }; + }, + snapPoints: bboxSnapPunten, + render(params, bbox) { + const L = vorstrandLayout(params); + const { p } = L; + const { S, X, Y } = pasIn(L.W, L.H, bbox); + const f = L.F * S; + const fijn = Math.max(0.3, Math.min(0.9, f * 0.05)); + const staafLw = Math.max(0.6, Math.min(2, p.netDiameter * S * 0.9)); + const cmds = []; + cmds.push({ kind: 'polyline', close: true, role: 'beton', points: L.omtrek.map((q) => ({ x: X(q.x), y: Y(q.y) })) }); + + for (const n of L.netten) { + cmds.push({ kind: 'line', x1: X(n.x1), y1: Y(n.y), x2: X(n.x2), y2: Y(n.y), lineWidth: staafLw, role: 'net' }); + for (const s of n.stippen) cmds.push(stip(X(s.x), Y(s.y), L.stipR * S, fijn)); + } + const b = L.beugel; + cmds.push({ + kind: 'polyline', close: true, role: 'beugel', lineWidth: staafLw, + points: [ + { x: X(b.x), y: Y(b.y) }, { x: X(b.x + b.w), y: Y(b.y) }, + { x: X(b.x + b.w), y: Y(b.y + b.h) }, { x: X(b.x), y: Y(b.y + b.h) }, + ], + }); + const hk = Math.min(b.w, b.h) * 0.18; + cmds.push({ kind: 'line', x1: X(b.x + hk * 0.3), y1: Y(b.y), x2: X(b.x + hk * 1.3), y2: Y(b.y + hk), lineWidth: staafLw, role: 'beugel' }); + cmds.push({ kind: 'line', x1: X(b.x), y1: Y(b.y + hk * 0.3), x2: X(b.x + hk), y2: Y(b.y + hk * 1.3), lineWidth: staafLw, role: 'beugel' }); + for (const r of L.rand) cmds.push(stip(X(r.x), Y(r.y), L.randR * S, fijn)); + + for (const a of L.aanwijzers) { + if (!a.nr) continue; + const r = staafnummerStraal(a.nr, L.F); + cmds.push({ kind: 'line', x1: X(a.x - r), y1: Y(a.y), x2: X(a.doel.x), y2: Y(a.doel.y), lineWidth: fijn, role: 'aanwijslijn' }); + cmds.push(...staafnummerBol(a.nr, X(a.x), Y(a.y), f)); + } + + if (p.toonMaten) { + const { ox, oy, F } = L; + const ms = f * 0.85; + // Hoogte vorstrand (links), breedte vorstrand (onder), vloerdikte (rechts). + cmds.push(...maatlijn(X(ox - F * 1.4), Y(oy + p.vorstrandHoogte), X(ox - F * 1.4), Y(oy), String(Math.round(p.vorstrandHoogte)), ms, -1)); + cmds.push(...maatlijn(X(ox), Y(oy + p.vorstrandHoogte + F * 1.2), X(ox + p.vorstrandBreedte), Y(oy + p.vorstrandHoogte + F * 1.2), String(Math.round(p.vorstrandBreedte)), ms, 1)); + const xr = ox + L.Lt + F * 1.4; + cmds.push(...maatlijn(X(xr), Y(oy), X(xr), Y(oy + p.vloerdikte), String(Math.round(p.vloerdikte)), ms, -1)); + } + + let y = L.legendaY; + for (const r of L.legenda) { + let x = L.F; + if (r.nr) { + const rr = staafnummerStraal(r.nr, L.F); + cmds.push(...staafnummerBol(r.nr, X(x + rr), Y(y), f)); + x += 2 * rr + L.F * 0.5; + } + const w = tekstBreedte(r.tekst, L.F); + cmds.push(...tekstMetDiameter(r.tekst, X(x + w / 2), Y(y), f, { role: 'legenda' })); + y += L.F * 1.6; + } + return cmds; + }, +}; diff --git a/open-pdf-studio/js/symbols/templates/vloer-vorstrand.test.mjs b/open-pdf-studio/js/symbols/templates/vloer-vorstrand.test.mjs new file mode 100644 index 000000000..99a24894b --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/vloer-vorstrand.test.mjs @@ -0,0 +1,72 @@ +// Parametrisch wapeningsdetail vloer met vorstrand (#312): geometrie uit de +// parameters, staven voor de buigstaat en tekening binnen de bbox. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { vloerVorstrandTemplate as tpl, vorstrandLayout, vorstrandStaven } from './vloer-vorstrand.js'; +import { defaultParams, getTemplate } from '../registry.js'; +import { staafLengte } from '../../quantities/wapening.js'; +import { assertBinnenBbox, teksten } from './binnen-bbox.testhulp.mjs'; + +test('geregistreerd met ware maat', () => { + assert.equal(getTemplate('vloer-vorstrand'), tpl); + const mm = tpl.realSizeMm(defaultParams(tpl)); + assert.ok(mm.width > 1600 && mm.height > 800); +}); + +test('betonomtrek: vloer + verdiepte rand, met breeklijn', () => { + const L = vorstrandLayout({ vloerdikte: 200, vorstrandBreedte: 400, vorstrandHoogte: 800, vloerLengte: 1200 }); + const xs = L.omtrek.map((p) => p.x), ys = L.omtrek.map((p) => p.y); + assert.equal(Math.min(...xs), L.ox); + assert.equal(Math.max(...ys) - Math.min(...ys), 800); + // Onderkant vloer ligt op de vloerdikte, de rand loopt door tot de vorstrandhoogte. + assert.ok(L.omtrek.some((p) => p.y === L.oy + 200 && p.x === L.ox + 400)); + assert.ok(L.omtrek.some((p) => p.y === L.oy + 800 && p.x === L.ox + 400)); + assert.equal(L.omtrek.length, 10, 'inclusief breeklijn'); +}); + +test('netlagen en stippen volgen de parameters', () => { + assert.equal(vorstrandLayout({}).netten.length, 2); + assert.deepEqual(vorstrandLayout({ netLagen: 'onder' }).netten.map((n) => n.laag), ['onder']); + const ruim = vorstrandLayout({ netAfstand: 300 }).netten[0].stippen.length; + const dicht = vorstrandLayout({ netAfstand: 100 }).netten[0].stippen.length; + assert.ok(dicht > ruim); + const L = vorstrandLayout({ dekking: 40, netDiameter: 8 }); + assert.equal(L.netten.find((n) => n.laag === 'boven').y, L.oy + 40 + 4); +}); + +test('langsstaven en beugel in de vorstrand', () => { + const L = vorstrandLayout({ randAantal: 5 }); + assert.equal(L.rand.length, 5); + assert.equal(L.rand.filter((r) => r.y < L.oy + 200).length, 3, 'drie boven, twee onder'); + assert.equal(L.beugel.w, 400 - 80); + assert.equal(L.beugel.h, 800 - 80); +}); + +test('staven voor de buigstaat over de randlengte', () => { + const s = vorstrandStaven({ lengte: 10000, dekking: 40, beugelAfstand: 200, staafnummerStart: '7' }); + assert.deepEqual([s.rand.staafnummer, s.rand.aantal, s.rand.lengteMm, s.rand.diameter], ['7', 4, 9920, 12]); + assert.equal(s.beugel.staafnummer, '8'); + assert.equal(s.beugel.aantal, 50); + assert.equal(s.beugel.vormcode, '51'); + assert.equal(s.beugel.lengteMm, staafLengte('51', { a: 320, b: 720 }, 8)); + assert.deepEqual(tpl.rebarItems({ lengte: 0 }).map((i) => i.aantal), [4, 0]); + assert.equal(vorstrandStaven({ staafnummerStart: '' }).rand.staafnummer, ''); +}); + +test('render binnen de bbox, met legenda en staafnummers', () => { + const p = defaultParams(tpl); + const mm = tpl.realSizeMm(p); + const bbox = { x: 10, y: 10, width: mm.width / 6, height: mm.height / 6 }; + for (const extra of [{}, { toonMaten: false }, { netLagen: 'boven', randAantal: 0 }, { vorstrandHoogte: 1200, vloerLengte: 400 }]) { + const q = { ...p, ...extra }; + const m = tpl.realSizeMm(q); + const b = { x: 10, y: 10, width: m.width / 6, height: m.height / 6 }; + assertBinnenBbox(tpl.render(q, b), b, JSON.stringify(extra)); + } + const t = teksten(tpl.render(p, bbox)); + assert.ok(t.includes('1') && t.includes('2'), 'staafnummers'); + assert.ok(t.some((s) => s.includes('lg=9920'))); + assert.ok(t.some((s) => s.startsWith('bgls'))); + assert.ok(t.includes('800') && t.includes('400') && t.includes('200'), 'maatvoering'); +}); diff --git a/open-pdf-studio/js/symbols/templates/wapening-lijn.js b/open-pdf-studio/js/symbols/templates/wapening-lijn.js index 0df1678f8..e430da63d 100644 --- a/open-pdf-studio/js/symbols/templates/wapening-lijn.js +++ b/open-pdf-studio/js/symbols/templates/wapening-lijn.js @@ -227,6 +227,16 @@ export const wapeningsstaafTemplate = { editableLabels(params, bbox) { return editableLineLabels(params || {}, bbox, false, true); }, + // Buigstaat (#312): een rechte staaf, aantal × lengte. + rebarItems(params) { + const p = params || {}; + return [{ + aantal: integer(p.aantal, 3, 1), + diameter: positiveNumber(p.diameter, 8, 1), + lengteMm: positiveNumber(p.lengte, 1600, 1), + vormcode: '00', + }]; + }, realSizeMm(params) { return { width: positiveNumber(params?.lengte, 1600, 1), height: 240 }; }, diff --git a/open-pdf-studio/js/symbols/templates/wapening-tekst.js b/open-pdf-studio/js/symbols/templates/wapening-tekst.js new file mode 100644 index 000000000..911d54674 --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapening-tekst.js @@ -0,0 +1,161 @@ +// Gedeelde tekenhulpen voor de wapenings- en staalverbindingssymbolen: +// passend schalen van een lokale mm-tekening in de bbox, tekst met het +// wapenings-diameterteken (als vector, uit annotations/stavenreeks.js), een +// staafnummer in een cirkel en een eenvoudige maatlijn. +// +// Alle functies zijn puur en leveren draw-commands voor de parametricSymbol- +// renderer (line / circle / polyline / text / rings). + +import { leesGetal } from '../../core/getal-invoer.js'; +import { approxTextWidth, diameterSignSegments } from '../../annotations/stavenreeks.js'; + +export { approxTextWidth }; + +export function getal(v, def, min = -Infinity) { + const n = typeof v === 'string' ? leesGetal(v) : Number(v); + return Number.isFinite(n) && n >= min ? n : def; +} + +export function geheelGetal(v, def, min = 0) { + return Math.max(min, Math.round(getal(v, def, min))); +} + +/** + * Past een lokale tekening (breedte W × hoogte H, y omlaag) gecentreerd in de + * bbox. Levert de schaal S en de mappers X/Y naar canvas-coördinaten. + */ +export function pasIn(W, H, bbox) { + const w = W > 0 ? W : 1; + const h = H > 0 ? H : 1; + const S = Math.min(bbox.width / w, bbox.height / h); + const x0 = bbox.x + (bbox.width - w * S) / 2; + const y0 = bbox.y + (bbox.height - h * S) / 2; + return { S, X: (v) => x0 + v * S, Y: (v) => y0 + v * S }; +} + +const TEKEN_BREEDTE = 0.68; // vak voor het diameterteken, × tekengrootte +const TUSSENRUIMTE = 0.18; + +/** Breedte van een tekst met diametertekens ('Ø' wordt als vector getekend). */ +export function tekstBreedte(tekst, size) { + const delen = String(tekst ?? '').split('Ø'); + let w = 0; + delen.forEach((deel, i) => { + if (i > 0) w += size * (TEKEN_BREEDTE + 2 * TUSSENRUIMTE); + if (deel) w += approxTextWidth(deel, size); + }); + return w; +} + +/** + * Tekst gecentreerd op (cx, cy). Elke 'Ø' wordt het wapenings-diameterteken + * (cirkel + steile streep met twee vlaggetjes), net als in stavenreeks en + * wapeningskorf — zo is het teken in de PDF-appearance font-onafhankelijk. + */ +export function tekstMetDiameter(tekst, cx, cy, size, extra = {}) { + const cmds = []; + const delen = String(tekst ?? '').split('Ø'); + const totaal = tekstBreedte(tekst, size); + let x = cx - totaal / 2; + const lw = Math.max(0.3, Math.min(0.8, size * 0.06)); + delen.forEach((deel, i) => { + if (i > 0) { + x += size * TUSSENRUIMTE; + const r = size * 0.22; + const sx = x + (size * TEKEN_BREEDTE) / 2; + cmds.push({ kind: 'circle', cx: sx, cy, r, lineWidth: lw, role: 'diameterteken' }); + for (const s of diameterSignSegments(r).segments) { + cmds.push({ + kind: 'line', x1: sx + s.x1, y1: cy - s.y1, x2: sx + s.x2, y2: cy - s.y2, + lineWidth: lw, role: 'diameterteken', + }); + } + x += size * (TEKEN_BREEDTE + TUSSENRUIMTE); + } + if (deel) { + const w = approxTextWidth(deel, size); + cmds.push({ kind: 'text', x: x + w / 2, y: cy, text: deel, size, ...extra }); + x += w; + } + }); + return cmds; +} + +/** Staafnummer in een cirkel (positienummer), gecentreerd op (cx, cy). */ +export function staafnummerBol(nummer, cx, cy, size) { + const tekst = String(nummer ?? ''); + const r = Math.max(size * 0.72, approxTextWidth(tekst, size) / 2 + size * 0.25); + return [ + { kind: 'circle', cx, cy, r, lineWidth: Math.max(0.3, size * 0.07), role: 'staafnummer' }, + { kind: 'text', x: cx, y: cy, text: tekst, size, role: 'staafnummer' }, + ]; +} + +/** Straal van staafnummerBol (voor de opmaak). */ +export function staafnummerStraal(nummer, size) { + return Math.max(size * 0.72, approxTextWidth(String(nummer ?? ''), size) / 2 + size * 0.25); +} + +/** Klein gevuld rondje (staaf in doorsnede) als veelhoek. */ +export function stip(cx, cy, r, lineWidth) { + const points = []; + for (let i = 0; i < 12; i++) { + const a = (i / 12) * Math.PI * 2; + points.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); + } + const c = { kind: 'polyline', fill: true, close: true, points, role: 'staaf' }; + if (lineWidth != null) c.lineWidth = lineWidth; + return c; +} + +/** Gesloten rechthoek als polyline. */ +export function rechthoek(x, y, w, h, extra = {}) { + return { + kind: 'polyline', close: true, + points: [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }], + ...extra, + }; +} + +/** + * Maatlijn tussen (x1,y1) en (x2,y2) (canvas), met schuine maatstreepjes en + * het maatgetal in het midden; `kant` (±1) kiest de zijde langs de normaal + * (-uy, ux) van de lijnrichting. + */ +export function maatlijn(x1, y1, x2, y2, tekst, size, kant = -1) { + const dx = x2 - x1, dy = y2 - y1; + const L = Math.hypot(dx, dy) || 1; + const ux = dx / L, uy = dy / L; + const nx = -uy, ny = ux; + const t = size * 0.35; + const streep = (px, py) => ({ + kind: 'line', + x1: px - (ux + nx) * t * 0.7, y1: py - (uy + ny) * t * 0.7, + x2: px + (ux + nx) * t * 0.7, y2: py + (uy + ny) * t * 0.7, + role: 'maatlijn', + }); + // Afstand tot de lijn: bij een liggende maatlijn de halve tekenhoogte, bij + // een staande de halve tekstbreedte (het getal staat altijd horizontaal). + const afstand = Math.abs(nx) * (approxTextWidth(String(tekst), size) / 2 + size * 0.35) + + Math.abs(ny) * size * 0.75; + const mx = (x1 + x2) / 2 + nx * kant * afstand; + const my = (y1 + y2) / 2 + ny * kant * afstand; + return [ + { kind: 'line', x1, y1, x2, y2, role: 'maatlijn' }, + streep(x1, y1), + streep(x2, y2), + { kind: 'text', x: mx, y: my, text: String(tekst), size, role: 'maatgetal' }, + ]; +} + +/** Standaard snappunten op de bbox (midden, hoeken, middens van de randen). */ +export function bboxSnapPunten(_params, bbox) { + const { x, y, width: w, height: h } = bbox; + return [ + { kind: 'center', x: x + w / 2, y: y + h / 2 }, + { kind: 'endpoint', x, y }, { kind: 'endpoint', x: x + w, y }, + { kind: 'endpoint', x, y: y + h }, { kind: 'endpoint', x: x + w, y: y + h }, + { kind: 'midpoint', x: x + w / 2, y }, { kind: 'midpoint', x: x + w / 2, y: y + h }, + { kind: 'midpoint', x, y: y + h / 2 }, { kind: 'midpoint', x: x + w, y: y + h / 2 }, + ]; +} diff --git a/open-pdf-studio/js/symbols/templates/wapeningsdetail.js b/open-pdf-studio/js/symbols/templates/wapeningsdetail.js new file mode 100644 index 000000000..8eba66e48 --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapeningsdetail.js @@ -0,0 +1,229 @@ +// Parametrisch WAPENINGSDETAIL (buigvorm) — de staafschets zoals op een +// buigstaat of wapeningstekening: de vorm met de buitenmaten langs de benen, +// het staafnummer in een cirkel en de notatie "12Ø10-150 lg=2450 B500B". +// +// De schets is SCHEMATISCH (niet op schaal, zoals gebruikelijk): de lange +// zijde krijgt een vaste breedte, korte benen blijven altijd zichtbaar. De +// maatgetallen zijn de echte buitenmaten; de staaflengte (lg) komt uit +// quantities/wapening.js (staafLengte), dezelfde functie die de buigstaat +// vult — tekening en staat kunnen dus niet uit elkaar lopen. +// +// Vormcodes volgen het nummerprincipe van NEN-EN ISO 3766 (eerste cijfer = +// aantal bochten): 00 recht, 11 haakstaaf (L), 21 U-staaf, 51 gesloten +// beugel met 135°-haken. + +import { + BUIGVORMEN, VORMCODES, STAALKWALITEITEN, STANDAARD_STAALKWALITEIT, + normaliseerVormcode, staafLengte, staafNotatie, +} from '../../quantities/wapening.js'; +import { + getal, geheelGetal, pasIn, tekstBreedte, tekstMetDiameter, staafnummerBol, + staafnummerStraal, maatlijn, bboxSnapPunten, +} from './wapening-tekst.js'; + +const F = 10; // tekengrootte in lokale eenheden +const SW = 200; // breedte van de langste horizontale zijde +const SH = 70; // maximale hoogte van een been +const SH_MIN = 22; // minimale hoogte van een been (blijft zichtbaar) + +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +/** Genormaliseerde parameters. */ +export function wapeningsdetailParams(params = {}) { + const p = params || {}; + const vormcode = normaliseerVormcode(p.vormcode ?? '21'); + const kw = String(p.staalkwaliteit || STANDAARD_STAALKWALITEIT).toUpperCase(); + return { + staafnummer: String(p.staafnummer ?? '1').trim(), + aantal: geheelGetal(p.aantal, 12, 0), + diameter: getal(p.diameter, 10, 1), + afstand: getal(p.afstand, 150, 0), + vormcode, + a: getal(p.a, 300, 0), + b: getal(p.b, 2000, 0), + c: getal(p.c, 300, 0), + staalkwaliteit: STAALKWALITEITEN.includes(kw) ? kw : STANDAARD_STAALKWALITEIT, + haaklengte: getal(p.haaklengte, 0, 0), + toonMaten: p.toonMaten !== false, + toonLengte: p.toonLengte !== false, + }; +} + +/** Staaflengte (mm) van het detail. */ +export function wapeningsdetailLengte(params) { + const p = wapeningsdetailParams(params); + return staafLengte(p.vormcode, p, p.diameter, { haaklengte: p.haaklengte }); +} + +/** Labeltekst zonder staafnummer: "12Ø10-150 lg=2450 B500B". */ +export function wapeningsdetailLabel(params) { + const p = wapeningsdetailParams(params); + const delen = [staafNotatie(p)]; + if (p.toonLengte) delen.push(`lg=${wapeningsdetailLengte(p)}`); + delen.push(p.staalkwaliteit); + return delen.join(' '); +} + +/** + * Schets van de buigvorm in lokale eenheden (origin linksboven van de vorm, + * y omlaag): lijnen (polylines), maatlijnen en de omvang. + */ +export function buigvormSchets(vormcode, seg) { + const code = normaliseerVormcode(vormcode); + const a = Math.max(0, getal(seg?.a, 0)), b = Math.max(0, getal(seg?.b, 0)), c = Math.max(0, getal(seg?.c, 0)); + const lijnen = []; + const maten = []; + let w = SW, h = 0; + switch (code) { + case '11': { + const k = a > 0 ? SW / a : 1; + h = clamp(b * k, SH_MIN, SH); + lijnen.push([{ x: 0, y: h }, { x: SW, y: h }, { x: SW, y: 0 }]); + maten.push({ seg: 'a', waarde: a, x1: 0, y1: h, x2: SW, y2: h, kant: 1 }); + maten.push({ seg: 'b', waarde: b, x1: SW, y1: h, x2: SW, y2: 0, kant: 1 }); + break; + } + case '21': { + const k = b > 0 ? SW / b : 1; + const been = Math.max(a, c); + h = clamp(been * k, SH_MIN, SH); + const ky = been > 0 ? h / been : 1; + const hA = Math.max(SH_MIN * 0.6, a * ky); + const hC = Math.max(SH_MIN * 0.6, c * ky); + h = Math.max(h, hA, hC); + lijnen.push([{ x: 0, y: h - hA }, { x: 0, y: h }, { x: SW, y: h }, { x: SW, y: h - hC }]); + maten.push({ seg: 'a', waarde: a, x1: 0, y1: h, x2: 0, y2: h - hA, kant: -1 }); + maten.push({ seg: 'b', waarde: b, x1: 0, y1: h, x2: SW, y2: h, kant: 1 }); + maten.push({ seg: 'c', waarde: c, x1: SW, y1: h, x2: SW, y2: h - hC, kant: 1 }); + break; + } + case '51': { + w = SW * 0.55; + const k = a > 0 ? w / a : 1; + h = clamp(b * k, SH_MIN, SH * 1.6); + lijnen.push([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }, { x: 0, y: 0 }]); + // Twee 135°-haken in de hoek linksboven, schuin de kern in. + const hk = Math.min(w, h) * 0.22; + lijnen.push([{ x: hk * 0.35, y: 0 }, { x: hk * 1.35, y: hk }]); + lijnen.push([{ x: 0, y: hk * 0.35 }, { x: hk, y: hk * 1.35 }]); + maten.push({ seg: 'a', waarde: a, x1: 0, y1: 0, x2: w, y2: 0, kant: -1 }); + maten.push({ seg: 'b', waarde: b, x1: w, y1: 0, x2: w, y2: h, kant: -1 }); + break; + } + default: { + lijnen.push([{ x: 0, y: 0 }, { x: SW, y: 0 }]); + maten.push({ seg: 'a', waarde: a, x1: 0, y1: 0, x2: SW, y2: 0, kant: 1 }); + } + } + return { code, lijnen, maten, w, h }; +} + +/** Volledige opmaak in lokale eenheden (pure functie, ook voor de tests). */ +export function wapeningsdetailLayout(params) { + const p = wapeningsdetailParams(params); + const schets = buigvormSchets(p.vormcode, p); + const label = wapeningsdetailLabel(p); + const bolR = p.staafnummer ? staafnummerStraal(p.staafnummer, F) : 0; + const labelW = (bolR ? 2 * bolR + F * 0.6 : 0) + tekstBreedte(label, F); + // Zijmarge: ruimte voor het breedste staande maatgetal (+ maatlijnafstand). + const staand = schets.maten.filter((m) => m.x1 === m.x2) + .map((m) => tekstBreedte(String(Math.round(m.waarde)), F * 0.85)); + const zijMarge = p.toonMaten && staand.length ? F * 1.9 + Math.max(...staand) : F; + const labelH = F * 1.9; + const boven = labelH + (p.toonMaten && (schets.code === '51') ? F * 2.6 : F * 1.0); + const onder = p.toonMaten && schets.code !== '51' ? F * 2 : F * 0.8; + const W = Math.max(schets.w + 2 * zijMarge, labelW + F); + const H = boven + schets.h + onder; + return { + p, schets, label, bolR, labelW, labelH, + vormX: (W - schets.w) / 2, vormY: boven, + footprint: { width: W, height: H }, + }; +} + +function rebarItems(params) { + const p = wapeningsdetailParams(params); + return [{ + staafnummer: p.staafnummer, + diameter: p.diameter, + aantal: p.aantal, + lengteMm: wapeningsdetailLengte(p), + vormcode: p.vormcode, + staalkwaliteit: p.staalkwaliteit, + afstand: p.afstand, + }]; +} + +export const wapeningsdetailTemplate = { + id: 'wapeningsdetail', + name: 'Wapeningsdetail (buigvorm)', + nameEn: 'Rebar detail (bending shape)', + category: 'NL Constructie', + defaultSize: { width: 280, height: 140 }, + zoektermen: ['buigvorm', 'buigstaat', 'staafvorm', 'haakstaaf', 'U-staaf', 'beugel', 'bending shape'], + params: [ + { key: 'staafnummer', label: 'Staafnummer', labelEn: 'Bar mark', type: 'string', default: '1' }, + { key: 'aantal', label: 'Aantal', labelEn: 'Number of bars', type: 'number', default: 12, min: 0, step: 1 }, + { key: 'diameter', label: 'Diameter Ø (mm)', labelEn: 'Diameter Ø (mm)', type: 'number', default: 10, min: 4, step: 2 }, + { key: 'afstand', label: 'H.o.h.-afstand (mm, 0 = geen)', labelEn: 'Spacing (mm, 0 = none)', type: 'number', default: 150, min: 0, step: 25 }, + { + key: 'vormcode', label: 'Buigvorm', labelEn: 'Shape code', type: 'enum', default: '21', + options: VORMCODES.map((c) => ({ value: c, label: `${c} – ${BUIGVORMEN[c].naam}` })), + }, + { key: 'a', label: 'Maat a (mm)', labelEn: 'Dimension a (mm)', type: 'number', default: 300, min: 0, step: 10 }, + { key: 'b', label: 'Maat b (mm)', labelEn: 'Dimension b (mm)', type: 'number', default: 2000, min: 0, step: 10 }, + { key: 'c', label: 'Maat c (mm)', labelEn: 'Dimension c (mm)', type: 'number', default: 300, min: 0, step: 10 }, + { + key: 'staalkwaliteit', label: 'Staalkwaliteit', labelEn: 'Steel grade', type: 'enum', + default: STANDAARD_STAALKWALITEIT, options: STAALKWALITEITEN, + }, + { key: 'haaklengte', label: 'Haakverlengte beugel (mm, 0 = 10Ø)', labelEn: 'Stirrup hook extension (mm, 0 = 10Ø)', type: 'number', default: 0, min: 0, step: 5 }, + { key: 'toonMaten', label: 'Maten tonen', labelEn: 'Show dimensions', type: 'boolean', default: true }, + { key: 'toonLengte', label: 'Staaflengte tonen', labelEn: 'Show bar length', type: 'boolean', default: true }, + ], + layout: wapeningsdetailLayout, + rebarItems, + snapPoints: bboxSnapPunten, + render(params, bbox) { + const L = wapeningsdetailLayout(params); + const { S, X, Y } = pasIn(L.footprint.width, L.footprint.height, bbox); + const cmds = []; + const f = F * S; + // Label: staafnummer-bol + notatie, samen gecentreerd boven de vorm. + const cy = Y(L.labelH / 2); + const start = X((L.footprint.width - L.labelW) / 2); + let tx = start; + if (L.bolR) { + cmds.push(...staafnummerBol(L.p.staafnummer, tx + L.bolR * S, cy, f)); + tx += (2 * L.bolR + F * 0.6) * S; + } + const tw = tekstBreedte(L.label, f); + cmds.push(...tekstMetDiameter(L.label, tx + tw / 2, cy, f, { role: 'label' })); + + // De staaf zelf (dikkere lijn: staaf = getekende wapening). + const staafLw = Math.max(0.8, Math.min(2.5, F * 0.16 * S)); + const vx = (v) => X(L.vormX + v); + const vy = (v) => Y(L.vormY + v); + for (const lijn of L.schets.lijnen) { + cmds.push({ + kind: 'polyline', role: 'staaf', lineWidth: staafLw, + points: lijn.map((pt) => ({ x: vx(pt.x), y: vy(pt.y) })), + }); + } + if (L.p.toonMaten) { + for (const m of L.schets.maten) { + const tekst = String(Math.round(m.waarde)); + const dx = m.x2 - m.x1, dy = m.y2 - m.y1; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len, ny = dx / len; + const afst = F * 1.1 * m.kant; + cmds.push(...maatlijn( + vx(m.x1 + nx * afst), vy(m.y1 + ny * afst), + vx(m.x2 + nx * afst), vy(m.y2 + ny * afst), + tekst, f * 0.85, m.kant, + )); + } + } + return cmds; + }, +}; diff --git a/open-pdf-studio/js/symbols/templates/wapeningsdetail.test.mjs b/open-pdf-studio/js/symbols/templates/wapeningsdetail.test.mjs new file mode 100644 index 000000000..aed6b5af1 --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapeningsdetail.test.mjs @@ -0,0 +1,87 @@ +// Wapeningsdetail (buigvorm, #312): schets per vormcode, notatie, staaflengte +// en de staaf die naar de buigstaat gaat. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + wapeningsdetailTemplate as tpl, buigvormSchets, wapeningsdetailLabel, wapeningsdetailLengte, + wapeningsdetailLayout, +} from './wapeningsdetail.js'; +import { defaultParams, getTemplate } from '../registry.js'; +import { staafLengte } from '../../quantities/wapening.js'; +import { assertBinnenBbox, teksten } from './binnen-bbox.testhulp.mjs'; + +const BBOX = { x: 30, y: 40, width: 280, height: 140 }; + +test('het template is geregistreerd met een buigvorm-keuze', () => { + assert.equal(getTemplate('wapeningsdetail'), tpl); + const vorm = tpl.params.find((p) => p.key === 'vormcode'); + assert.deepEqual(vorm.options.map((o) => o.value), ['00', '11', '21', '51']); + assert.equal(defaultParams(tpl).staalkwaliteit, 'B500B'); +}); + +test('schets per vormcode: aantal benen en maatgetallen', () => { + const recht = buigvormSchets('00', { a: 4000 }); + assert.equal(recht.lijnen[0].length, 2); + assert.deepEqual(recht.maten.map((m) => [m.seg, m.waarde]), [['a', 4000]]); + + const l = buigvormSchets('11', { a: 2500, b: 400 }); + assert.equal(l.lijnen[0].length, 3); + assert.deepEqual(l.maten.map((m) => m.seg), ['a', 'b']); + + const u = buigvormSchets('21', { a: 300, b: 2000, c: 500 }); + assert.equal(u.lijnen[0].length, 4); + assert.deepEqual(u.maten.map((m) => [m.seg, m.waarde]), [['a', 300], ['b', 2000], ['c', 500]]); + // Het langere been is ook langer getekend. + const hoogte = (pts) => Math.abs(pts[1].y - pts[0].y); + assert.ok(hoogte(u.lijnen[0].slice(2)) > hoogte(u.lijnen[0].slice(0, 2))); + + const beugel = buigvormSchets('51', { a: 300, b: 500 }); + assert.equal(beugel.lijnen.length, 3, 'omtrek + twee haken'); + assert.ok(beugel.h > beugel.w, 'hoge beugel staat rechtop'); +}); + +test('korte benen blijven zichtbaar', () => { + const u = buigvormSchets('21', { a: 10, b: 5000, c: 10 }); + assert.ok(u.h >= 13); +}); + +test('notatie en staaflengte', () => { + const p = { staafnummer: '3', aantal: 12, diameter: 10, afstand: 150, vormcode: '21', a: 300, b: 2000, c: 300 }; + assert.equal(wapeningsdetailLengte(p), 2560); + assert.equal(wapeningsdetailLabel(p), '12Ø10-150 lg=2560 B500B'); + assert.equal(wapeningsdetailLabel({ ...p, afstand: 0, toonLengte: false, staalkwaliteit: 'B500A' }), '12Ø10 B500A'); + assert.equal( + wapeningsdetailLengte({ ...p, vormcode: '51', a: 300, b: 500, haaklengte: 120 }), + staafLengte('51', { a: 300, b: 500 }, 10, { haaklengte: 120 }), + ); +}); + +test('render: label met staafnummer, maatgetallen, alles binnen de bbox', () => { + for (const vormcode of ['00', '11', '21', '51']) { + const p = { ...defaultParams(tpl), vormcode }; + const cmds = tpl.render(p, BBOX); + assertBinnenBbox(cmds, BBOX, `vorm ${vormcode}`); + const t = teksten(cmds); + assert.ok(t.includes('1'), 'staafnummer'); + assert.ok(t.some((s) => s.includes('lg=')), 'staaflengte in het label'); + assert.ok(cmds.some((c) => c.role === 'diameterteken'), 'diameterteken als vector'); + assert.ok(cmds.some((c) => c.role === 'staaf')); + } + const zonder = tpl.render({ ...defaultParams(tpl), toonMaten: false }, BBOX); + assert.ok(!zonder.some((c) => c.role === 'maatgetal')); + assert.ok(tpl.render(defaultParams(tpl), BBOX).filter((c) => c.role === 'maatgetal').length === 3); +}); + +test('de footprint groeit mee met een lang label', () => { + const kort = wapeningsdetailLayout({ vormcode: '00', a: 1000 }); + const lang = wapeningsdetailLayout({ vormcode: '00', a: 1000, staafnummer: '123', aantal: 1000, staalkwaliteit: 'B500C' }); + assert.ok(lang.footprint.width >= kort.footprint.width); +}); + +test('rebarItems: één positie voor de buigstaat', () => { + const items = tpl.rebarItems({ ...defaultParams(tpl), staafnummer: '7' }); + assert.deepEqual(items, [{ + staafnummer: '7', diameter: 10, aantal: 12, lengteMm: 2560, vormcode: '21', staalkwaliteit: 'B500B', afstand: 150, + }]); +}); diff --git a/open-pdf-studio/js/symbols/templates/wapeningskorf-staat.test.mjs b/open-pdf-studio/js/symbols/templates/wapeningskorf-staat.test.mjs new file mode 100644 index 000000000..c8999c449 --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapeningskorf-staat.test.mjs @@ -0,0 +1,29 @@ +// Wapeningskorf (parametrische rechthoekige balk) in de buigstaat (#312). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { wapeningskorfTemplate as tpl, wapeningskorfStaven } from './wapeningskorf.js'; +import { defaultParams } from '../registry.js'; +import { staafLengte } from '../../quantities/wapening.js'; + +test('zonder balklengte telt de korf niet mee', () => { + assert.deepEqual(wapeningskorfStaven(defaultParams(tpl)), []); + assert.deepEqual(tpl.rebarItems({}), []); +}); + +test('staven over de balklengte met doorlopende staafnummers', () => { + const items = tpl.rebarItems({ ...defaultParams(tpl), lengte: 6000, staafnummerStart: '11' }); + assert.deepEqual(items.map((i) => [i.staafnummer, i.aantal, i.diameter, i.lengteMm, i.vormcode]), [ + ['11', 4, 12, 5940, '00'], + ['12', 6, 16, 5940, '00'], + ['13', 2, 10, 5940, '00'], + ['14', 40, 8, staafLengte('51', { a: 340, b: 340 }, 8), '51'], + ]); + assert.ok(items.every((i) => i.staalkwaliteit === 'B500B')); +}); + +test('lege staafgroep valt weg, nummering automatisch zonder startnummer', () => { + const items = tpl.rebarItems({ lengte: 3000, zijAantal: 0, staalkwaliteit: 'B500C' }); + assert.equal(items.length, 3); + assert.ok(items.every((i) => i.staafnummer === '' && i.staalkwaliteit === 'B500C')); +}); diff --git a/open-pdf-studio/js/symbols/templates/wapeningskorf.js b/open-pdf-studio/js/symbols/templates/wapeningskorf.js index 15e31e1d1..f424cd6b1 100644 --- a/open-pdf-studio/js/symbols/templates/wapeningskorf.js +++ b/open-pdf-studio/js/symbols/templates/wapeningskorf.js @@ -23,6 +23,9 @@ // `realSizeMm` levert `{ width: breedte, height: hoogte }`, zodat het blok bij // plaatsing schaalgebied-bewust wordt gemaat (net als de staalprofielen). +import { + STAALKWALITEITEN, STANDAARD_STAALKWALITEIT, aantalOverBreedte, staafLengte, +} from '../../quantities/wapening.js'; import { labelLayout, diameterSignSegments, @@ -282,6 +285,40 @@ function _stirrupLabel(cmds, X, Y, S, x, yMid, prefix, d, afstand, font) { return wl + gap + signW + gap + wr; } +/** + * Staven van de korf over de balklengte: boven-, onder- en zijstaven + * (recht, lengte − 2 × dekking) en de gesloten beugels (vorm 51, buitenmaat + * = doorsnede − 2 × dekking). Zonder balklengte: geen staven. + */ +export function wapeningskorfStaven(params) { + const p = params || {}; + const L = layoutMm(p); + const lengte = _num(p.lengte, 0, 0); + if (!(lengte > 0)) return []; + const kw = String(p.staalkwaliteit || STANDAARD_STAALKWALITEIT).toUpperCase(); + const staalkwaliteit = STAALKWALITEITEN.includes(kw) ? kw : STANDAARD_STAALKWALITEIT; + const start = Number(p.staafnummerStart); + const metNummer = String(p.staafnummerStart ?? '').trim() !== '' && Number.isInteger(start); + let volgend = start; + const nr = () => (metNummer ? String(volgend++) : ''); + const netto = Math.max(0, lengte - 2 * L.dekking); + const uit = []; + const recht = (aantal, diameter, omschrijving) => { + if (aantal > 0) uit.push({ staafnummer: nr(), aantal, diameter, lengteMm: Math.round(netto), vormcode: '00', staalkwaliteit, omschrijving }); + }; + recht(L.bovenAantal, L.bovenDiameter, 'boven'); + recht(L.onderAantal, L.onderDiameter, 'onder'); + recht(L.zijAantal, L.zijDiameter, 'zij'); + const a = Math.max(0, L.breedte - 2 * L.dekking); + const b = Math.max(0, L.hoogte - 2 * L.dekking); + uit.push({ + staafnummer: nr(), aantal: aantalOverBreedte(netto, L.beugelAfstand), diameter: L.beugelDiameter, + afstand: L.beugelAfstand, lengteMm: staafLengte('51', { a, b }, L.beugelDiameter), + vormcode: '51', staalkwaliteit, omschrijving: 'beugel', + }); + return uit; +} + export const wapeningskorfTemplate = { id: 'wapeningskorf', name: 'Wapeningskorf', @@ -304,8 +341,18 @@ export const wapeningskorfTemplate = { { key: 'naam', label: 'Onderschrift', labelEn: 'Caption', type: 'string', default: 'Korf A' }, { key: 'toonMaatlijnen', label: 'Maatlijnen tonen', labelEn: 'Show dimensions', type: 'boolean', default: true }, { key: 'toonLabels', label: 'Labels tonen', labelEn: 'Show labels', type: 'boolean', default: true }, + // Buigstaat (#312): zonder balklengte telt de korf niet mee in de staat. + { key: 'lengte', label: 'Balklengte voor staat (mm, 0 = niet tellen)', labelEn: 'Beam length for schedule (mm, 0 = not counted)', type: 'number', default: 0, min: 0, step: 100 }, + { key: 'staafnummerStart', label: 'Eerste staafnummer', labelEn: 'First bar mark', type: 'string', default: '' }, + { + key: 'staalkwaliteit', label: 'Staalkwaliteit', labelEn: 'Steel grade', type: 'enum', + default: STANDAARD_STAALKWALITEIT, options: STAALKWALITEITEN, + }, ], + // Staven over de balklengte voor de buigstaat (quantities/wapening.js). + rebarItems: wapeningskorfStaven, + // Lokale mm-geometrie — ook het aangrijpingspunt voor de unittest. layoutMm, diff --git a/open-pdf-studio/js/symbols/templates/wapeningsnet.js b/open-pdf-studio/js/symbols/templates/wapeningsnet.js new file mode 100644 index 000000000..0388cadac --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapeningsnet.js @@ -0,0 +1,171 @@ +// Parametrisch WAPENINGSNET in plattegrond — Nederlandse tekenwijze: de +// netomtrek op ware grootte met één diagonaal en het netlabel +// "Ø8-150/Ø8-150" plus de netmaat en de laag. Een bovennet wordt gestreept +// getekend, een onder-net doorgetrokken. +// +// Het net levert twee staafposities aan de buigstaat (quantities/wapening.js): +// de staven in de lengterichting (aantal over de breedte) en die in de +// breedterichting (aantal over de lengte), × het aantal netten. + +import { STAALKWALITEITEN, aantalOverBreedte, staafNotatie } from '../../quantities/wapening.js'; +import { + getal, geheelGetal, pasIn, tekstBreedte, tekstMetDiameter, staafnummerBol, + staafnummerStraal, bboxSnapPunten, +} from './wapening-tekst.js'; + +export const NET_STANDAARD_KWALITEIT = 'B500A'; + +export function wapeningsnetParams(params = {}) { + const p = params || {}; + const kw = String(p.staalkwaliteit || NET_STANDAARD_KWALITEIT).toUpperCase(); + return { + lengte: getal(p.lengte, 5000, 100), + breedte: getal(p.breedte, 2150, 100), + diameterL: getal(p.diameterL, 8, 1), + afstandL: getal(p.afstandL, 150, 10), + diameterB: getal(p.diameterB, 8, 1), + afstandB: getal(p.afstandB, 150, 10), + laag: p.laag === 'boven' ? 'boven' : 'onder', + aantalNetten: geheelGetal(p.aantalNetten, 1, 1), + staafnummerL: String(p.staafnummerL ?? '').trim(), + staafnummerB: String(p.staafnummerB ?? '').trim(), + staalkwaliteit: STAALKWALITEITEN.includes(kw) ? kw : NET_STANDAARD_KWALITEIT, + }; +} + +/** Netnotatie "Ø8-150/Ø8-150" (lengterichting / breedterichting). */ +export function netNotatie(params) { + const p = wapeningsnetParams(params); + const l = staafNotatie({ diameter: p.diameterL, afstand: p.afstandL }); + const b = staafNotatie({ diameter: p.diameterB, afstand: p.afstandB }); + return `${p.aantalNetten > 1 ? `${p.aantalNetten}× ` : ''}${l}/${b}`; +} + +/** Tweede labelregel: "5000×2150 onder B500A". */ +export function netOnderschrift(params) { + const p = wapeningsnetParams(params); + return `${Math.round(p.lengte)}×${Math.round(p.breedte)} ${p.laag} ${p.staalkwaliteit}`; +} + +/** Staafaantallen en -lengtes van één net. */ +export function netStaven(params) { + const p = wapeningsnetParams(params); + return { + lengterichting: { aantal: aantalOverBreedte(p.breedte, p.afstandL), lengteMm: Math.round(p.lengte) }, + breedterichting: { aantal: aantalOverBreedte(p.lengte, p.afstandB), lengteMm: Math.round(p.breedte) }, + }; +} + +/** Opmaak in lokale mm (y omlaag): label, diagonaal met onderbreking. */ +export function wapeningsnetLayout(params) { + const p = wapeningsnetParams(params); + const r1 = netNotatie(p); + const r2 = netOnderschrift(p); + const nr = p.staafnummerL || p.staafnummerB; + // Tekengrootte: past in 85 % van de netlengte en hooguit 9 % van de breedte. + const perFont = Math.max(tekstBreedte(r1, 1) + (nr ? 2 * staafnummerStraal(nr, 1) + 0.6 : 0), tekstBreedte(r2, 1)); + const font = Math.max(1, Math.min(p.breedte * 0.09, (p.lengte * 0.85) / perFont)); + const cx = p.lengte / 2, cy = p.breedte / 2; + const hw = (perFont * font) / 2 + font * 0.4; + const hh = font * 1.5; + const g = Math.min(hw / p.lengte, hh / p.breedte); + return { + p, r1, r2, nr, font, + label: { cx, cy, hw, hh }, + diagonaal: { t0: 0.5 - g, t1: 0.5 + g }, + footprint: { width: p.lengte, height: p.breedte }, + }; +} + +function rebarItems(params) { + const p = wapeningsnetParams(params); + const s = netStaven(p); + return [ + { + staafnummer: p.staafnummerL, diameter: p.diameterL, afstand: p.afstandL, + aantal: s.lengterichting.aantal * p.aantalNetten, lengteMm: s.lengterichting.lengteMm, + vormcode: '00', staalkwaliteit: p.staalkwaliteit, omschrijving: 'net', + }, + { + staafnummer: p.staafnummerB, diameter: p.diameterB, afstand: p.afstandB, + aantal: s.breedterichting.aantal * p.aantalNetten, lengteMm: s.breedterichting.lengteMm, + vormcode: '00', staalkwaliteit: p.staalkwaliteit, omschrijving: 'net', + }, + ]; +} + +export const wapeningsnetTemplate = { + id: 'wapeningsnet', + name: 'Wapeningsnet (plattegrond)', + nameEn: 'Reinforcement mesh (plan)', + category: 'NL Constructie', + defaultSize: { width: 250, height: 108 }, + fixedSize: true, + zoektermen: ['net', 'wapeningsnet', 'mesh', 'stalen net', 'bovennet', 'ondernet'], + params: [ + { key: 'lengte', label: 'Netlengte (mm)', labelEn: 'Mesh length (mm)', type: 'number', default: 5000, min: 100, step: 50 }, + { key: 'breedte', label: 'Netbreedte (mm)', labelEn: 'Mesh width (mm)', type: 'number', default: 2150, min: 100, step: 50 }, + { key: 'diameterL', label: 'Ø lengterichting (mm)', labelEn: 'Ø longitudinal (mm)', type: 'number', default: 8, min: 4, step: 1 }, + { key: 'afstandL', label: 'H.o.h. lengterichting (mm)', labelEn: 'Spacing longitudinal (mm)', type: 'number', default: 150, min: 10, step: 25 }, + { key: 'diameterB', label: 'Ø breedterichting (mm)', labelEn: 'Ø transverse (mm)', type: 'number', default: 8, min: 4, step: 1 }, + { key: 'afstandB', label: 'H.o.h. breedterichting (mm)', labelEn: 'Spacing transverse (mm)', type: 'number', default: 150, min: 10, step: 25 }, + { + key: 'laag', label: 'Laag', labelEn: 'Layer', type: 'enum', default: 'onder', + options: [{ value: 'onder', label: 'Onder (doorgetrokken)' }, { value: 'boven', label: 'Boven (gestreept)' }], + }, + { key: 'aantalNetten', label: 'Aantal netten', labelEn: 'Number of meshes', type: 'number', default: 1, min: 1, step: 1 }, + { key: 'staafnummerL', label: 'Staafnummer lengterichting', labelEn: 'Bar mark longitudinal', type: 'string', default: '' }, + { key: 'staafnummerB', label: 'Staafnummer breedterichting', labelEn: 'Bar mark transverse', type: 'string', default: '' }, + { + key: 'staalkwaliteit', label: 'Staalkwaliteit', labelEn: 'Steel grade', type: 'enum', + default: NET_STANDAARD_KWALITEIT, options: STAALKWALITEITEN, + }, + ], + layout: wapeningsnetLayout, + rebarItems, + realSizeMm(params) { + const p = wapeningsnetParams(params); + return { width: p.lengte, height: p.breedte }; + }, + snapPoints: bboxSnapPunten, + render(params, bbox) { + const L = wapeningsnetLayout(params); + const { p } = L; + const { S, X, Y } = pasIn(L.footprint.width, L.footprint.height, bbox); + const f = L.font * S; + const dash = p.laag === 'boven' ? [Math.max(3, f * 0.6), Math.max(2, f * 0.35)] : undefined; + const cmds = []; + const omtrek = { + kind: 'polyline', close: true, role: 'net', + points: [ + { x: X(0), y: Y(0) }, { x: X(p.lengte), y: Y(0) }, + { x: X(p.lengte), y: Y(p.breedte) }, { x: X(0), y: Y(p.breedte) }, + ], + }; + if (dash) omtrek.dash = dash; + cmds.push(omtrek); + // Diagonaal linksonder → rechtsboven, onderbroken rond het label. + const pt = (t) => ({ x: X(t * p.lengte), y: Y(p.breedte - t * p.breedte) }); + for (const [t0, t1] of [[0, L.diagonaal.t0], [L.diagonaal.t1, 1]]) { + if (t1 <= t0) continue; + const a = pt(t0), b = pt(t1); + const lijn = { kind: 'line', x1: a.x, y1: a.y, x2: b.x, y2: b.y, role: 'diagonaal' }; + if (dash) lijn.dash = dash; + cmds.push(lijn); + } + const cx = X(L.label.cx); + const y1 = Y(L.label.cy) - f * 0.65; + const y2 = Y(L.label.cy) + f * 0.65; + const w1 = tekstBreedte(L.r1, f); + const bolR = L.nr ? staafnummerStraal(L.nr, f) : 0; + const totaal = w1 + (bolR ? 2 * bolR + f * 0.6 : 0); + let x = cx - totaal / 2; + if (bolR) { + cmds.push(...staafnummerBol(L.nr, x + bolR, y1, f)); + x += 2 * bolR + f * 0.6; + } + cmds.push(...tekstMetDiameter(L.r1, x + w1 / 2, y1, f, { role: 'label' })); + cmds.push({ kind: 'text', x: cx, y: y2, text: L.r2, size: f * 0.85, role: 'onderschrift' }); + return cmds; + }, +}; diff --git a/open-pdf-studio/js/symbols/templates/wapeningsnet.test.mjs b/open-pdf-studio/js/symbols/templates/wapeningsnet.test.mjs new file mode 100644 index 000000000..ce5fc5baf --- /dev/null +++ b/open-pdf-studio/js/symbols/templates/wapeningsnet.test.mjs @@ -0,0 +1,55 @@ +// Wapeningsnet in plattegrond (#312): netnotatie, staafaantallen, tekening. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { wapeningsnetTemplate as tpl, netNotatie, netOnderschrift, netStaven, wapeningsnetLayout } from './wapeningsnet.js'; +import { defaultParams, getTemplate } from '../registry.js'; +import { assertBinnenBbox, teksten } from './binnen-bbox.testhulp.mjs'; + +const BBOX = { x: 0, y: 0, width: 250, height: 107.5 }; + +test('geregistreerd, ware maat = netmaat', () => { + assert.equal(getTemplate('wapeningsnet'), tpl); + assert.deepEqual(tpl.realSizeMm(defaultParams(tpl)), { width: 5000, height: 2150 }); + assert.equal(tpl.fixedSize, true); +}); + +test('netnotatie en onderschrift', () => { + assert.equal(netNotatie({}), 'Ø8-150/Ø8-150'); + assert.equal(netNotatie({ diameterL: 10, afstandL: 100, diameterB: 6, afstandB: 200, aantalNetten: 3 }), '3× Ø10-100/Ø6-200'); + assert.equal(netOnderschrift({ laag: 'boven' }), '5000×2150 boven B500A'); +}); + +test('staafaantallen over lengte en breedte', () => { + const s = netStaven({ lengte: 5000, breedte: 2150, afstandL: 150, afstandB: 150 }); + assert.deepEqual(s, { + lengterichting: { aantal: 15, lengteMm: 5000 }, + breedterichting: { aantal: 34, lengteMm: 2150 }, + }); + const items = tpl.rebarItems({ aantalNetten: 2, staafnummerL: '5', staafnummerB: '6' }); + assert.deepEqual(items.map((i) => [i.staafnummer, i.aantal, i.lengteMm, i.diameter, i.staalkwaliteit]), [ + ['5', 30, 5000, 8, 'B500A'], + ['6', 68, 2150, 8, 'B500A'], + ]); +}); + +test('render: omtrek, onderbroken diagonaal, label; bovennet gestreept', () => { + const onder = tpl.render(defaultParams(tpl), BBOX); + assertBinnenBbox(onder, BBOX, 'ondernet'); + assert.equal(onder.filter((c) => c.role === 'diagonaal').length, 2, 'diagonaal rond het label onderbroken'); + assert.ok(!onder.some((c) => c.dash)); + assert.ok(teksten(onder).includes('5000×2150 onder B500A')); + + const boven = tpl.render({ ...defaultParams(tpl), laag: 'boven', staafnummerL: '3' }, BBOX); + assertBinnenBbox(boven, BBOX, 'bovennet'); + assert.ok(boven.filter((c) => c.role === 'diagonaal' || c.role === 'net').every((c) => Array.isArray(c.dash))); + assert.ok(boven.some((c) => c.role === 'staafnummer' && c.kind === 'text' && c.text === '3')); +}); + +test('het label past binnen het net, ook bij een smal net', () => { + for (const p of [{}, { lengte: 1200, breedte: 2000 }, { lengte: 6000, breedte: 300 }]) { + const L = wapeningsnetLayout(p); + assert.ok(L.label.hw * 2 <= L.footprint.width, 'labelbreedte'); + assert.ok(L.diagonaal.t0 > 0 && L.diagonaal.t1 < 1); + } +}); diff --git a/open-pdf-studio/js/types/annotation.ts b/open-pdf-studio/js/types/annotation.ts index 2a17cafaf..14e00b437 100644 --- a/open-pdf-studio/js/types/annotation.ts +++ b/open-pdf-studio/js/types/annotation.ts @@ -238,6 +238,8 @@ export type Annotation = AnnotationBase & { count?: number; // aantal staven (= poten/punten) diameter?: number; // staafdiameter in mm (6..40) barLengthMm?: number; // staaflengte in mm (0 = onbekend) + barMark?: string; // staafnummer in de buigstaat ('' = automatisch) + steelGrade?: string; // staalkwaliteit ('' = B500B) legDir?: 'down-left' | 'down-right' | 'up-left' | 'up-right'; legLength?: number; // pootlengte in px (canvas) lineTail?: number; // uitloop van de aanhaallijn voorbij de laatste poot (px) diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index 9b8b6b39a..db129dc86 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -577,3 +577,11 @@ js/annotations/rendering/textbox-vuitlijning.test.mjs js/ui/lint-keuzelijst-thema.test.mjs js/tools/tools/dubbelklik-greep.test.mjs js/solid/components/ribbon/lint-tooltips-i18n.test.mjs +js/quantities/wapening.test.mjs +js/symbols/templates/wapeningsdetail.test.mjs +js/symbols/templates/wapeningsnet.test.mjs +js/symbols/templates/staaf-plattegrond.test.mjs +js/symbols/templates/vloer-vorstrand.test.mjs +js/symbols/templates/staalverbindingen.test.mjs +js/symbols/templates/wapeningskorf-staat.test.mjs +js/pdf/wapening-rondgang.test.mjs diff --git a/open-pdf-studio/src-tauri/src/mcp_server.rs b/open-pdf-studio/src-tauri/src/mcp_server.rs index 7ee6e5669..bd2e75be8 100644 --- a/open-pdf-studio/src-tauri/src/mcp_server.rs +++ b/open-pdf-studio/src-tauri/src/mcp_server.rs @@ -2308,7 +2308,7 @@ fn handle_tools_list() -> Value { "type": "object", "properties": { "scheduleId": { "type": "string", "description": "Reuse an existing schedule instead of creating one." }, - "templateId": { "type": "string", "description": "Template for a new schedule: area, length, count, symbol, text, image or full. Default: area." }, + "templateId": { "type": "string", "description": "Template for a new schedule: area, length, count, symbol, text, image, full, buigstaat (rebar bending schedule) or wapeningsstaat (rebar weight per diameter). Default: area." }, "name": { "type": "string", "description": "Display name for the schedule." }, "config": { "type": "object", "description": "Schedule config: categories, fields, filters, sort, itemize, format. Overrides the template.", "additionalProperties": true }, "page": { "type": "number", "description": "Page to place the table on (default: current page)." }, From 51f29904e954c945b4147c0b0e81909f4a5ea7f2 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 10:05:04 +0100 Subject: [PATCH 08/13] feat(ink): pressure-sensitive ink with smoothed strokes (#781) - Freehand tool reads every coalesced pointer sample (getCoalescedEvents) before the per-frame move batching; pen pressure drives a per-point width, mouse and touch keep a constant width. - Zero-phase smoothing plus centripetal Catmull-Rom; variable-width strokes render as filled outline rings with round caps, split at sharp corners and folding inner offsets (no spikes or holes, nonzero fill). - Palm rejection: touch is ignored while a pen is near, a second pointer never joins a stroke, optional pen-only mode. - Save: /Ink keeps the centre line in /InkList and the max width in /BS; the appearance stream draws the smoothed variable-width stroke. Per-point pressure, minimum width and smoothing round-trip in OPS_ keys. - Properties panel Ink section and freehand preferences (pressure on/off, minimum width, smoothing, pen only), translated in all locales. - Tests: width mapping, smoothing, outline geometry, palm filter, coalesced samples, mouse fallback and real saver/loader round-trip incl. rotation. Fixes on top during review: a dashed freehand stroke now writes its dash pattern into the appearance stream, so other readers show it dashed too. A filled outline cannot carry a dash, so a dashed stroke with pressure falls back to a stroked, dashed centre line at full width, on screen and in the appearance; the line style is part of the geometry cache key. The Ink key read in the loader no longer sits inside the arc comment block. Tests check the dash operator in the saved appearance and that the dash patterns match the screen. Fixes #385 --- .../js/annotations/pressure-ink-panel.js | 25 + .../annotations/pressure-ink-wiring.test.mjs | 71 +++ .../js/annotations/pressure-ink.js | 494 ++++++++++++++++++ .../js/annotations/pressure-ink.test.mjs | 331 ++++++++++++ open-pdf-studio/js/annotations/rendering.js | 26 + open-pdf-studio/js/core/constants.ts | 7 + open-pdf-studio/js/core/preferences.js | 15 + open-pdf-studio/js/core/stijl-mapping.js | 2 +- .../js/i18n/locales/ar/preferences.json | 8 +- .../js/i18n/locales/ar/properties.json | 9 + .../js/i18n/locales/bg/preferences.json | 8 +- .../js/i18n/locales/bg/properties.json | 9 + .../js/i18n/locales/bn/preferences.json | 8 +- .../js/i18n/locales/bn/properties.json | 9 + .../js/i18n/locales/ca/preferences.json | 8 +- .../js/i18n/locales/ca/properties.json | 9 + .../js/i18n/locales/cs/preferences.json | 8 +- .../js/i18n/locales/cs/properties.json | 9 + .../js/i18n/locales/da/preferences.json | 8 +- .../js/i18n/locales/da/properties.json | 9 + .../js/i18n/locales/de/preferences.json | 8 +- .../js/i18n/locales/de/properties.json | 9 + .../js/i18n/locales/el/preferences.json | 8 +- .../js/i18n/locales/el/properties.json | 9 + .../js/i18n/locales/en/preferences.json | 8 +- .../js/i18n/locales/en/properties.json | 9 + .../js/i18n/locales/es/preferences.json | 8 +- .../js/i18n/locales/es/properties.json | 9 + .../js/i18n/locales/fa/preferences.json | 8 +- .../js/i18n/locales/fa/properties.json | 9 + .../js/i18n/locales/fi/preferences.json | 8 +- .../js/i18n/locales/fi/properties.json | 9 + .../js/i18n/locales/fr/preferences.json | 8 +- .../js/i18n/locales/fr/properties.json | 9 + .../js/i18n/locales/he/preferences.json | 8 +- .../js/i18n/locales/he/properties.json | 9 + .../js/i18n/locales/hi/preferences.json | 8 +- .../js/i18n/locales/hi/properties.json | 9 + .../js/i18n/locales/hr/preferences.json | 8 +- .../js/i18n/locales/hr/properties.json | 9 + .../js/i18n/locales/hu/preferences.json | 8 +- .../js/i18n/locales/hu/properties.json | 9 + .../js/i18n/locales/id/preferences.json | 8 +- .../js/i18n/locales/id/properties.json | 9 + .../js/i18n/locales/it/preferences.json | 8 +- .../js/i18n/locales/it/properties.json | 9 + .../js/i18n/locales/ja/preferences.json | 8 +- .../js/i18n/locales/ja/properties.json | 9 + .../js/i18n/locales/ko/preferences.json | 8 +- .../js/i18n/locales/ko/properties.json | 9 + .../js/i18n/locales/lo/preferences.json | 8 +- .../js/i18n/locales/lo/properties.json | 9 + .../js/i18n/locales/ms/preferences.json | 8 +- .../js/i18n/locales/ms/properties.json | 9 + .../js/i18n/locales/nb/preferences.json | 8 +- .../js/i18n/locales/nb/properties.json | 9 + .../js/i18n/locales/nl/preferences.json | 8 +- .../js/i18n/locales/nl/properties.json | 9 + .../js/i18n/locales/pl/preferences.json | 8 +- .../js/i18n/locales/pl/properties.json | 9 + .../js/i18n/locales/pt/preferences.json | 8 +- .../js/i18n/locales/pt/properties.json | 9 + .../js/i18n/locales/ro/preferences.json | 8 +- .../js/i18n/locales/ro/properties.json | 9 + .../js/i18n/locales/ru/preferences.json | 8 +- .../js/i18n/locales/ru/properties.json | 9 + .../js/i18n/locales/sk/preferences.json | 8 +- .../js/i18n/locales/sk/properties.json | 9 + .../js/i18n/locales/sr/preferences.json | 8 +- .../js/i18n/locales/sr/properties.json | 9 + .../js/i18n/locales/sv/preferences.json | 8 +- .../js/i18n/locales/sv/properties.json | 9 + .../js/i18n/locales/sw/preferences.json | 8 +- .../js/i18n/locales/sw/properties.json | 9 + .../js/i18n/locales/ta/preferences.json | 8 +- .../js/i18n/locales/ta/properties.json | 9 + .../js/i18n/locales/th/preferences.json | 8 +- .../js/i18n/locales/th/properties.json | 9 + .../js/i18n/locales/tr/preferences.json | 8 +- .../js/i18n/locales/tr/properties.json | 9 + .../js/i18n/locales/uk/preferences.json | 8 +- .../js/i18n/locales/uk/properties.json | 9 + .../js/i18n/locales/ur/preferences.json | 8 +- .../js/i18n/locales/ur/properties.json | 9 + .../js/i18n/locales/vi/preferences.json | 8 +- .../js/i18n/locales/vi/properties.json | 9 + .../js/i18n/locales/zh/preferences.json | 8 +- .../js/i18n/locales/zh/properties.json | 9 + .../js/pdf/drukinkt-rondgang.test.mjs | 172 ++++++ .../js/pdf/loader/annotation-converter.js | 5 +- .../js/pdf/loader/color-extraction.js | 4 + .../js/pdf/loader/inkt-uit-bestand.js | 17 + open-pdf-studio/js/pdf/saver.js | 20 +- open-pdf-studio/js/pdf/saver/inkt-sleutels.js | 65 +++ .../components/preferences/AnnotationsTab.jsx | 20 + .../properties-panel/InkSection.jsx | 48 ++ .../properties-panel/PropertiesPanel.jsx | 2 + .../js/solid/stores/propertiesStore.js | 23 + open-pdf-studio/js/tools/tool-dispatcher.js | 15 + open-pdf-studio/js/tools/tools/draw-tool.js | 177 ++++++- open-pdf-studio/js/types/annotation.ts | 12 + open-pdf-studio/js/types/preferences.ts | 4 + open-pdf-studio/scripts/unit-testlijst.txt | 3 + 103 files changed, 2169 insertions(+), 69 deletions(-) create mode 100644 open-pdf-studio/js/annotations/pressure-ink-panel.js create mode 100644 open-pdf-studio/js/annotations/pressure-ink-wiring.test.mjs create mode 100644 open-pdf-studio/js/annotations/pressure-ink.js create mode 100644 open-pdf-studio/js/annotations/pressure-ink.test.mjs create mode 100644 open-pdf-studio/js/pdf/drukinkt-rondgang.test.mjs create mode 100644 open-pdf-studio/js/pdf/loader/inkt-uit-bestand.js create mode 100644 open-pdf-studio/js/pdf/saver/inkt-sleutels.js create mode 100644 open-pdf-studio/js/solid/components/properties-panel/InkSection.jsx diff --git a/open-pdf-studio/js/annotations/pressure-ink-panel.js b/open-pdf-studio/js/annotations/pressure-ink-panel.js new file mode 100644 index 000000000..b2da4177d --- /dev/null +++ b/open-pdf-studio/js/annotations/pressure-ink-panel.js @@ -0,0 +1,25 @@ +// Vrije hand (#385) in het eigenschappenpaneel: wat het paneel toont en wat +// een wijziging in het model schrijft. Pure module (onder node te testen). + +import { INK_STANDAARD, klemGladheid, klemMinBreedte } from './pressure-ink.js'; + +/** De paneelwaarden van een streek (een oude streek: gladheid 0, druk aan). */ +export function inktPaneelWaarden(ann) { + return { + inkPressure: ann?.inkPressure !== false, + inkHasPressure: Array.isArray(ann?.pressures) && ann.pressures.length > 0, + inkMinWidth: klemMinBreedte(ann?.inkMinWidth ?? INK_STANDAARD.minBreedtePct), + inkSmoothing: klemGladheid(ann?.inkSmoothing ?? 0), + }; +} + +/** Wat een wijziging in het paneel in het model zet. */ +export function inktPropWaarde(key, value) { + switch (key) { + // Uit is false; aan laat de sleutel weg (standaard). + case 'inkPressure': return { inkPressure: value === true || value === 'true' ? undefined : false }; + case 'inkMinWidth': return { inkMinWidth: klemMinBreedte(value) }; + case 'inkSmoothing': return { inkSmoothing: klemGladheid(value) }; + default: return {}; + } +} diff --git a/open-pdf-studio/js/annotations/pressure-ink-wiring.test.mjs b/open-pdf-studio/js/annotations/pressure-ink-wiring.test.mjs new file mode 100644 index 000000000..ca8c2a692 --- /dev/null +++ b/open-pdf-studio/js/annotations/pressure-ink-wiring.test.mjs @@ -0,0 +1,71 @@ +// Drukgevoelige inkt (#385): de bedrading die zonder de app niet te draaien +// is (bronteksten, CRLF→LF), het paneel en de vertalingen in alle talen. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { inktPaneelWaarden, inktPropWaarde } from './pressure-ink-panel.js'; + +const bron = (pad) => readFileSync(new URL(pad, import.meta.url), 'utf8').replace(/\r\n/g, '\n'); + +test('dispatcher: ruwe pointermoves (coalesced) en palmafwijzing gaan naar het gereedschap', () => { + const d = bron('../tools/tool-dispatcher.js'); + assert.match(d, /rejectPointer\(e, state\)\) return;/, 'pointerdown: een geweigerde aanwijzer doet niets'); + assert.match(d, /if \(state\.isDrawing\) \{\n\s+const _ruw = getTool\(state\.currentTool\);\n\s+if \(_ruw\?\.onRawPointerMove\) _ruw\.onRawPointerMove\(e, state\);/, + 'elke ruwe pointermove vóór de bundeling per beeldframe'); + const t = bron('../tools/tools/draw-tool.js'); + assert.match(t, /monstersVanEvent\(e\)/, 'de vrije hand leest alle coalesced monsters'); + assert.match(t, /aanwijzerMetDruk\(e\?\.pointerType\)/, 'alleen een pen stuurt de breedte'); +}); + +test('scherm en bestand tekenen dezelfde streek (rendering en saver gebruiken inktGeometrie)', () => { + assert.match(bron('./rendering.js'), /inktGeometrie\(annotation, \{ breedteFactor/); + assert.match(bron('../pdf/saver.js'), /inktAppearanceInhoud\(ann, convertX, convertY/); + assert.match(bron('../pdf/loader/annotation-converter.js'), /\.\.\.inktUitBestand\(extraColors, path\.length\)/); +}); + +test('paneel: waarden van een streek en wat een wijziging schrijft', () => { + assert.deepEqual(inktPaneelWaarden({ type: 'draw', path: [] }), + { inkPressure: true, inkHasPressure: false, inkMinWidth: 30, inkSmoothing: 0 }); + assert.deepEqual(inktPaneelWaarden({ pressures: [0.2], inkPressure: false, inkMinWidth: 2, inkSmoothing: 70 }), + { inkPressure: false, inkHasPressure: true, inkMinWidth: 5, inkSmoothing: 70 }); + assert.deepEqual(inktPropWaarde('inkPressure', false), { inkPressure: false }); + assert.deepEqual(inktPropWaarde('inkPressure', true), { inkPressure: undefined }); + assert.deepEqual(inktPropWaarde('inkMinWidth', '250'), { inkMinWidth: 100 }); + assert.deepEqual(inktPropWaarde('inkSmoothing', 33.4), { inkSmoothing: 33 }); +}); + +test('voorkeuren: standaardwaarden bestaan', () => { + const c = bron('../core/constants.ts'); + for (const k of ['drawPressure: true', 'drawPressureMinWidth: 30', 'drawSmoothing: 50', 'drawPenOnly: false']) { + assert.ok(c.includes(k), k); + } +}); + +test('vertalingen: elke taal heeft de inkt-teksten, echt vertaald', () => { + const map = new URL('../i18n/locales/', import.meta.url); + const talen = readdirSync(map); + assert.ok(talen.length >= 40); + const lees = (taal, ns) => JSON.parse(readFileSync(new URL(`${taal}/${ns}.json`, map), 'utf8')); + const sleutels = { + properties: (d) => d.ink, + preferences: (d) => d.drawing, + }; + const velden = { + properties: ['title', 'pressure', 'pressureHint', 'minWidth', 'minWidthHint', 'smoothing', 'smoothingHint'], + preferences: ['smoothing', 'pressure', 'pressureHint', 'pressureMinWidth', 'penOnly', 'penOnlyHint'], + }; + for (const ns of Object.keys(velden)) { + const en = sleutels[ns](lees('en', ns)); + for (const veld of velden[ns]) { + let gelijk = 0; + for (const taal of talen) { + const v = sleutels[ns](lees(taal, ns))?.[veld]; + assert.equal(typeof v, 'string', `${taal}/${ns}: ${veld} ontbreekt`); + assert.ok(v.trim().length > 0, `${taal}/${ns}: ${veld} is leeg`); + if (taal !== 'en' && v === en[veld]) gelijk++; + } + assert.ok(gelijk <= 2, `${ns}.${veld}: ${gelijk} talen gebruiken de Engelse tekst`); + } + } +}); diff --git a/open-pdf-studio/js/annotations/pressure-ink.js b/open-pdf-studio/js/annotations/pressure-ink.js new file mode 100644 index 000000000..d98090f83 --- /dev/null +++ b/open-pdf-studio/js/annotations/pressure-ink.js @@ -0,0 +1,494 @@ +// Drukgevoelige inkt met gladde streken (#385). +// +// Pure geometrie, zonder app-imports: onder node te testen en door het +// scherm (rendering.js), de saver (appearance-stream) en het gereedschap +// (draw-tool.js) gedeeld, zodat het scherm en het bestand dezelfde streek +// tonen. +// +// Het model van een vrije-handstreek ('draw'): +// - path de middellijn zoals hij ingevoerd is (ook /InkList); +// - pressures optioneel: de pendruk 0..1 per punt van path (alleen bij +// een pen; een muis of vinger geeft een vaste breedte); +// - lineWidth de grootste breedte (bij volle druk); +// - inkMinWidth de breedte bij druk 0, in procenten van lineWidth; +// - inkSmoothing gladheid 0..100 (0 = de oude kale polylijn); +// - inkPressure false zet de druk uit zonder hem weg te gooien. +// +// De breedte per punt: lineWidth × (min + (1 − min) × druk). Gladmaken: +// eerst een fasevrij exponentieel filter (voor- én achteruit, zodat de streek +// niet achter de pen aanloopt en begin en eind blijven liggen), dan een +// centripetale Catmull-Rom-spline door de gefilterde punten. Een streek met +// variabele breedte wordt een gevulde omtrek: ringen met ronde kappen, alle +// met dezelfde omloop, zodat de nonzero-vulregel ze samenvoegt. Een scherpe +// hoek en een stuk waar de binnenkant zou omslaan (een krappe bocht smaller +// dan de streek) worden een eigen ring, zodat er nooit gaten of uitschieters +// ontstaan. +// +// Een gestreepte streek (borderStyle niet doorgetrokken) kan geen gevulde +// omtrek zijn: een vulling draagt geen streeppatroon. Zo'n streek wordt altijd +// een getrokken, gestreepte middellijn met de volle breedte, op het scherm en +// in de appearance, ook als hij druk heeft. + +export const INK_STANDAARD = Object.freeze({ + minBreedtePct: 30, + gladheid: 50, +}); + +const EPS = 1e-6; + +// Streeppatronen per borderStyle, gelijk aan applyBorderStyle in +// rendering/decorations.js (schermpixels = PDF-punten bij schaal 1). +const STREEPPATRONEN = Object.freeze({ + 'dashed': [3, 4], + 'dotted': [2, 4], + 'dash-dot': [10, 8, 2, 8], + 'dash-dot-dot': [10, 8, 2, 8, 2, 8], + 'long-dash': [20, 10], + 'long-dash-dot': [20, 10, 2, 10], + 'long-dash-dot-dot': [20, 10, 2, 10, 2, 10], +}); + +/** Streeppatroon van een borderStyle, of null voor een doorgetrokken lijn. */ +export function inktStreepPatroon(borderStyle) { + const d = STREEPPATRONEN[borderStyle]; + return d ? d.slice() : null; +} + +/** Druk naar 0..1; een ontbrekende of ongeldige waarde is de helft (de waarde die een browser zonder druk meldt). */ +export function klemDruk(p) { + return Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : 0.5; +} + +/** Minimale breedte in procenten, 5..100 (geheel getal). */ +export function klemMinBreedte(pct) { + const n = Number(pct); + if (!Number.isFinite(n)) return INK_STANDAARD.minBreedtePct; + return Math.round(Math.min(100, Math.max(5, n))); +} + +/** Gladheid 0..100 (geheel getal). */ +export function klemGladheid(v) { + const n = Number(v); + if (!Number.isFinite(n)) return INK_STANDAARD.gladheid; + return Math.round(Math.min(100, Math.max(0, n))); +} + +/** De breedte bij een druk: lineWidth × (min + (1 − min) × druk). */ +export function breedteUitDruk(druk, maxBreedte, minPct = INK_STANDAARD.minBreedtePct) { + const max = Number.isFinite(maxBreedte) && maxBreedte > 0 ? maxBreedte : 2; + const min = klemMinBreedte(minPct) / 100; + return max * (min + (1 - min) * klemDruk(druk)); +} + +/** Heeft deze streek een bruikbare druk per punt (en staat de druk aan)? */ +export function heeftDruk(ann) { + return !!ann && ann.inkPressure !== false + && Array.isArray(ann.path) && Array.isArray(ann.pressures) + && ann.path.length > 0 && ann.pressures.length === ann.path.length; +} + +/** Tekent deze streek anders dan de oude kale polylijn? */ +export function isGladdeInkt(ann) { + return heeftDruk(ann) || klemGladheid(ann?.inkSmoothing ?? 0) > 0; +} + +/** + * De drukreeks zoals hij in het model komt: null als er niets te variëren + * valt (een muis meldt 0,5 of 0, een pen zonder druksensor steeds hetzelfde), + * anders afgerond op drie decimalen. + */ +export function drukReeksVoorOpslag(pressures, aantalPunten) { + if (!Array.isArray(pressures) || pressures.length === 0) return null; + if (aantalPunten !== undefined && pressures.length !== aantalPunten) return null; + let min = Infinity, max = -Infinity; + const uit = pressures.map((p) => { + const k = Math.round(klemDruk(p) * 1000) / 1000; + if (k < min) min = k; + if (k > max) max = k; + return k; + }); + return max - min < 0.02 ? null : uit; +} + +/** + * De inkt-eigenschappen van een nieuwe streek: gladheid altijd; druk en + * minimale breedte alleen bij een pen met echte drukvariatie (een muis en + * een pen zonder druksensor houden een vaste breedte). + * @param {object} prefs state.preferences + * @param {{metDruk:boolean, drukken:number[]}|null} streek + * @param {number} aantalPunten het aantal punten van path + */ +export function inktEigenschappen(prefs, streek, aantalPunten) { + const uit = { inkSmoothing: klemGladheid(prefs?.drawSmoothing ?? INK_STANDAARD.gladheid) }; + const drukken = streek?.metDruk ? drukReeksVoorOpslag(streek.drukken, aantalPunten) : null; + if (drukken) { + uit.pressures = drukken; + uit.inkMinWidth = klemMinBreedte(prefs?.drawPressureMinWidth ?? INK_STANDAARD.minBreedtePct); + } + return uit; +} + +/** + * Of een aanwijzer druk meldt die de breedte mag sturen. Alleen een pen: + * een muis is altijd constant (de browser meldt 0,5 bij ingedrukte knop) en + * een vinger meldt meestal een vaste of geen druk. + */ +export function aanwijzerMetDruk(pointerType) { + return pointerType === 'pen'; +} + +/** + * De monsters van één pointermove: met getCoalescedEvents alle tussenliggende + * metingen (een pen meet 120–240 keer per seconde, de browser bundelt ze per + * beeldframe), anders het event zelf. + * @returns {{clientX:number, clientY:number, pressure:number}[]} + */ +export function monstersVanEvent(e) { + let lijst = null; + try { + if (e && typeof e.getCoalescedEvents === 'function') lijst = e.getCoalescedEvents(); + } catch (_) { lijst = null; } + if (!lijst || lijst.length === 0) lijst = [e]; + return lijst.map((m) => ({ clientX: m.clientX, clientY: m.clientY, pressure: m.pressure })); +} + +// ── Palmafwijzing ─────────────────────────────────────────────────────────── + +/** + * Palmafwijzing: zolang een pen in de buurt is (zweven of schrijven, tot + * `nawerking` ms na het laatste pen-event), telt een aanraking niet mee voor + * het tekenen; met `alleenPen` nooit. Een tweede aanwijzer tijdens een streek + * wordt altijd genegeerd. + */ +export function maakPalmFilter({ nawerking = 600 } = {}) { + let laatstePen = -Infinity; + return { + noteer(e, nu) { + if (e && e.pointerType === 'pen') laatstePen = nu; + }, + weiger(e, nu, { alleenPen = false } = {}) { + if (!e || e.pointerType !== 'touch') return false; + if (alleenPen) return true; + return nu - laatstePen <= nawerking; + }, + reset() { laatstePen = -Infinity; }, + }; +} + +// ── Gladmaken ─────────────────────────────────────────────────────────────── + +/** Punten (met hun waarde) zonder (bijna) dubbele opvolgers. */ +export function zonderDubbele(punten, waarden, minAfstand = 1e-3) { + const p = [], w = []; + for (let i = 0; i < punten.length; i++) { + const q = punten[i]; + if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y)) continue; + const vorige = p[p.length - 1]; + if (vorige && Math.hypot(q.x - vorige.x, q.y - vorige.y) < minAfstand) { + // Dezelfde plek: de grootste breedte blijft. + w[w.length - 1] = Math.max(w[w.length - 1], waarden[i]); + continue; + } + p.push({ x: q.x, y: q.y }); + w.push(waarden[i]); + } + return { punten: p, waarden: w }; +} + +/** + * Fasevrij exponentieel filter: één keer vooruit, één keer achteruit, het + * gemiddelde. Begin- en eindpunt blijven liggen. Sterkte 0..100. + */ +export function gladFilter(reeks, sterkte) { + const n = reeks.length; + const k = (klemGladheid(sterkte) / 100) * 0.85; // gewicht van het vorige punt + if (n < 3 || k <= 0) return reeks.slice(); + const vooruit = new Array(n), achteruit = new Array(n); + vooruit[0] = reeks[0]; + for (let i = 1; i < n; i++) vooruit[i] = vooruit[i - 1] + (reeks[i] - vooruit[i - 1]) * (1 - k); + achteruit[n - 1] = reeks[n - 1]; + for (let i = n - 2; i >= 0; i--) achteruit[i] = achteruit[i + 1] + (reeks[i] - achteruit[i + 1]) * (1 - k); + const uit = new Array(n); + uit[0] = reeks[0]; + uit[n - 1] = reeks[n - 1]; + for (let i = 1; i < n - 1; i++) uit[i] = (vooruit[i] + achteruit[i]) / 2; + return uit; +} + +/** + * Centripetale Catmull-Rom door de punten, met de waarden lineair mee. + * `stap` is de gewenste afstand tussen twee uitvoerpunten (paginapunten). + */ +export function catmullRom(punten, waarden, { stap = 1, maxPerSegment = 16 } = {}) { + const n = punten.length; + if (n < 3) return { punten: punten.map((p) => ({ x: p.x, y: p.y })), waarden: waarden.slice() }; + const P = (i) => { + if (i < 0) return { x: 2 * punten[0].x - punten[1].x, y: 2 * punten[0].y - punten[1].y }; + if (i >= n) return { x: 2 * punten[n - 1].x - punten[n - 2].x, y: 2 * punten[n - 1].y - punten[n - 2].y }; + return punten[i]; + }; + const tj = (ti, a, b) => ti + Math.max(Math.sqrt(Math.hypot(b.x - a.x, b.y - a.y)), EPS); + const lerp = (a, b, t0, t1, t) => { + const d = t1 - t0; + if (d < EPS) return { x: a.x, y: a.y }; + const u = (t - t0) / d; + return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u }; + }; + const uitP = [{ x: punten[0].x, y: punten[0].y }]; + const uitW = [waarden[0]]; + for (let i = 0; i < n - 1; i++) { + const p0 = P(i - 1), p1 = P(i), p2 = P(i + 1), p3 = P(i + 2); + const len = Math.hypot(p2.x - p1.x, p2.y - p1.y); + const delen = Math.max(1, Math.min(maxPerSegment, Math.ceil(len / stap))); + const t0 = 0, t1 = tj(t0, p0, p1), t2 = tj(t1, p1, p2), t3 = tj(t2, p2, p3); + for (let s = 1; s <= delen; s++) { + const u = s / delen; + let q; + if (s === delen) q = { x: p2.x, y: p2.y }; + else { + const t = t1 + (t2 - t1) * u; + const a1 = lerp(p0, p1, t0, t1, t), a2 = lerp(p1, p2, t1, t2, t), a3 = lerp(p2, p3, t2, t3, t); + const b1 = lerp(a1, a2, t0, t2, t), b2 = lerp(a2, a3, t1, t3, t); + q = lerp(b1, b2, t1, t2, t); + } + uitP.push(q); + uitW.push(waarden[i] + (waarden[i + 1] - waarden[i]) * u); + } + } + return { punten: uitP, waarden: uitW }; +} + +/** + * De middellijn zoals hij getekend wordt, met de breedte per punt. + * @param {{x:number,y:number}[]} path + * @param {number[]} breedtes breedte per punt van path + * @param {number} gladheid 0..100 + */ +export function gladdeStreek(path, breedtes, gladheid, { stap = 1 } = {}) { + const schoon = zonderDubbele(path || [], breedtes || []); + const sterkte = klemGladheid(gladheid); + if (sterkte <= 0 || schoon.punten.length < 3) return schoon; + const xs = gladFilter(schoon.punten.map((p) => p.x), sterkte); + const ys = gladFilter(schoon.punten.map((p) => p.y), sterkte); + // De breedte iets minder glad dan de lijn: een drukaccent mag blijven. + const ws = gladFilter(schoon.waarden, sterkte / 2); + const gefilterd = zonderDubbele(xs.map((x, i) => ({ x, y: ys[i] })), ws); + return catmullRom(gefilterd.punten, gefilterd.waarden, { stap }); +} + +/** De breedte per punt van de streek (vast als er geen druk is). */ +export function breedtesVan(ann, { breedteFactor = 1 } = {}) { + const max = (Number.isFinite(ann?.lineWidth) && ann.lineWidth > 0 ? ann.lineWidth : 2) * breedteFactor; + const n = ann?.path?.length || 0; + if (!heeftDruk(ann)) return new Array(n).fill(max); + const minPct = ann.inkMinWidth ?? INK_STANDAARD.minBreedtePct; + return ann.pressures.map((p) => breedteUitDruk(p, max, minPct)); +} + +// ── Omtrek ────────────────────────────────────────────────────────────────── + +function cirkelRing(c, r, stappen) { + const ring = []; + for (let i = 0; i < stappen; i++) { + const a = -(i / stappen) * Math.PI * 2; // zelfde omloop als de streekringen + ring.push({ x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r }); + } + return ring; +} + +function normalenVan(P, s, e) { + const normalen = []; + for (let i = s; i <= e; i++) { + let tx = 0, ty = 0; + if (i > s) { + const d = Math.hypot(P[i].x - P[i - 1].x, P[i].y - P[i - 1].y) || 1; + tx += (P[i].x - P[i - 1].x) / d; ty += (P[i].y - P[i - 1].y) / d; + } + if (i < e) { + const d = Math.hypot(P[i + 1].x - P[i].x, P[i + 1].y - P[i].y) || 1; + tx += (P[i + 1].x - P[i].x) / d; ty += (P[i + 1].y - P[i].y) / d; + } + let l = Math.hypot(tx, ty); + if (l < EPS && i > s) { + // Haarspeld: de inkomende richting. + tx = P[i].x - P[i - 1].x; ty = P[i].y - P[i - 1].y; l = Math.hypot(tx, ty) || 1; + } + if (l < EPS) { tx = 1; ty = 0; l = 1; } + normalen.push({ x: -ty / l, y: tx / l }); + } + return normalen; +} + +/** Het eerste segment (index k: k→k+1) waar een zijde van [s..e] terugloopt, of -1. */ +function eersteOmslag(P, R, N, s, e) { + for (let k = s; k < e; k++) { + const dx = P[k + 1].x - P[k].x, dy = P[k + 1].y - P[k].y; + const a = N[k - s], b = N[k + 1 - s]; + const lx = (P[k + 1].x + b.x * R[k + 1]) - (P[k].x + a.x * R[k]); + const ly = (P[k + 1].y + b.y * R[k + 1]) - (P[k].y + a.y * R[k]); + const rx = (P[k + 1].x - b.x * R[k + 1]) - (P[k].x - a.x * R[k]); + const ry = (P[k + 1].y - b.y * R[k + 1]) - (P[k].y - a.y * R[k]); + if (lx * dx + ly * dy <= 0 || rx * dx + ry * dy <= 0) return k; + } + return -1; +} + +function streekRing(P, R, N, s, e, kapStappen) { + const links = [], rechts = []; + for (let i = s; i <= e; i++) { + const n = N[i - s]; + links.push({ x: P[i].x + n.x * R[i], y: P[i].y + n.y * R[i] }); + rechts.push({ x: P[i].x - n.x * R[i], y: P[i].y - n.y * R[i] }); + } + const ring = links.slice(); + // Eindkap: van links naar rechts om het eindpunt, door de looprichting. + const ne = N[e - s], ae = Math.atan2(ne.y, ne.x); + for (let j = 1; j < kapStappen; j++) { + const a = ae - Math.PI * j / kapStappen; + ring.push({ x: P[e].x + Math.cos(a) * R[e], y: P[e].y + Math.sin(a) * R[e] }); + } + for (let i = rechts.length - 1; i >= 0; i--) ring.push(rechts[i]); + // Beginkap: van rechts terug naar links om het beginpunt. + const ns = N[0], as = Math.atan2(ns.y, ns.x); + for (let j = 1; j < kapStappen; j++) { + const a = as + Math.PI - Math.PI * j / kapStappen; + ring.push({ x: P[s].x + Math.cos(a) * R[s], y: P[s].y + Math.sin(a) * R[s] }); + } + return ring; +} + +/** + * De gevulde omtrek van een streek met variabele breedte: een lijst ringen + * (gesloten veelhoeken) die samen, met de nonzero-regel gevuld, de streek + * vormen. Elke ring heeft ronde kappen; alle ringen hebben dezelfde omloop. + * @param {{x:number,y:number}[]} punten middellijn + * @param {number[]} breedtes volle breedte per punt + * @param {{scherpeHoek?:number, kapStappen?:number}} [opties] + * scherpeHoek: draaiing in graden waarboven de streek in twee ringen breekt + * @returns {{x:number,y:number}[][]} + */ +export function omtrekRingen(punten, breedtes, { scherpeHoek = 100, kapStappen = 8 } = {}) { + const schoon = zonderDubbele(punten || [], (breedtes || []).map((w) => (Number.isFinite(w) && w > 0 ? w : 0))); + const P = schoon.punten; + const R = schoon.waarden.map((w) => w / 2); + const n = P.length; + if (n === 0) return []; + if (n === 1) return R[0] > 0 ? [cirkelRing(P[0], R[0], kapStappen * 2)] : []; + + // Breek bij scherpe hoeken: beide stukken eindigen rond in de hoek. + const grens = Math.cos((scherpeHoek * Math.PI) / 180); + const stukken = []; + let begin = 0; + for (let i = 1; i < n - 1; i++) { + const ax = P[i].x - P[i - 1].x, ay = P[i].y - P[i - 1].y; + const bx = P[i + 1].x - P[i].x, by = P[i + 1].y - P[i].y; + const cos = (ax * bx + ay * by) / ((Math.hypot(ax, ay) * Math.hypot(bx, by)) || 1); + if (cos < grens) { stukken.push([begin, i]); begin = i; } + } + stukken.push([begin, n - 1]); + + const ringen = []; + const werk = stukken.reverse(); + while (werk.length) { + const [s, e] = werk.pop(); + if (e <= s) continue; + const N = normalenVan(P, s, e); + const k = e - s >= 2 ? eersteOmslag(P, R, N, s, e) : -1; + if (k < 0) { + let maxR = 0; + for (let i = s; i <= e; i++) if (R[i] > maxR) maxR = R[i]; + if (maxR <= 0) continue; + const len = e - s === 1 ? Math.hypot(P[e].x - P[s].x, P[e].y - P[s].y) : Infinity; + // Twee punten waarvan de ene cirkel de andere omvat: alleen de grote. + if (Math.abs(R[e] - R[s]) >= len) ringen.push(cirkelRing(R[e] > R[s] ? P[e] : P[s], maxR, kapStappen * 2)); + else ringen.push(streekRing(P, R, N, s, e, kapStappen)); + continue; + } + // Een omslaande zijde: dat segment apart (een ring van twee punten slaat + // nooit om), de rest links en rechts ervan opnieuw. + werk.push([k + 1, e]); + werk.push([k, k + 1]); + if (k > s) werk.push([s, k]); + } + return ringen; +} + +// ── Alles samen, met cache ────────────────────────────────────────────────── + +const _cache = new WeakMap(); + +function vingerafdruk(ann, breedteFactor, stap) { + let som = 0; + const path = ann.path || []; + for (let i = 0; i < path.length; i++) som += path[i].x * 31 + path[i].y * 17 + i; + let drukSom = 0; + if (heeftDruk(ann)) for (const p of ann.pressures) drukSom += p; + return [path.length, som, drukSom, ann.lineWidth, ann.inkMinWidth, ann.inkSmoothing, + ann.inkPressure, heeftDruk(ann), ann.borderStyle, breedteFactor, stap].join('|'); +} + +/** + * Wat er van een streek getekend wordt. + * @returns {{ variabel: boolean, punten: {x,y}[], breedtes: number[], ringen: {x,y}[][] }} + * variabel: true → vul de ringen; false → trek de middellijn met breedtes[0]. + * Een gestreepte streek is nooit variabel: hij wordt met de volle breedte + * getrokken, zodat het streeppatroon zichtbaar blijft. + */ +export function inktGeometrie(ann, { breedteFactor = 1, stap = 1 } = {}) { + if (!ann || !Array.isArray(ann.path)) return { variabel: false, punten: [], breedtes: [], ringen: [] }; + const sleutel = vingerafdruk(ann, breedteFactor, stap); + const oud = _cache.get(ann); + if (oud && oud.sleutel === sleutel) return oud.uit; + const variabel = heeftDruk(ann) && !inktStreepPatroon(ann.borderStyle); + const breedtes = variabel || !heeftDruk(ann) + ? breedtesVan(ann, { breedteFactor }) + : breedtesVan({ lineWidth: ann.lineWidth, path: ann.path }, { breedteFactor }); + const glad = gladdeStreek(ann.path, breedtes, ann.inkSmoothing ?? 0, { stap }); + const uit = { + variabel, + punten: glad.punten, + breedtes: glad.waarden, + ringen: variabel ? omtrekRingen(glad.punten, glad.waarden) : [], + }; + _cache.set(ann, { sleutel, uit }); + return uit; +} + +/** Grootste halve breedte (voor /Rect en de omhullende). */ +export function halveMaxBreedte(ann) { + const w = Number.isFinite(ann?.lineWidth) && ann.lineWidth > 0 ? ann.lineWidth : 2; + return w / 2; +} + +/** + * Content-stream (absolute PDF-coördinaten) voor de /AP van een gladde of + * drukgevoelige streek. X en Y zetten app-coördinaten om naar PDF. + * Variabel: gevulde ringen (f, nonzero). Vast: de gladde middellijn (S) met + * ronde kappen en hoeken, en het streeppatroon (d) bij een gestreepte streek, + * zodat andere lezers hem ook gestreept tonen. + * @returns {string|null} + */ +export function inktAppearanceInhoud(ann, X, Y, rgb) { + const geo = inktGeometrie(ann); + if (geo.punten.length === 0) return null; + const f = (v) => (Math.round(v * 1000) / 1000).toString(); + const [r, g, b] = rgb.map((c) => f(c)); + const regels = []; + if (geo.variabel) { + if (geo.ringen.length === 0) return null; + regels.push(`${r} ${g} ${b} rg`); + for (const ring of geo.ringen) { + ring.forEach((p, i) => regels.push(`${f(X(p.x))} ${f(Y(p.y))} ${i === 0 ? 'm' : 'l'}`)); + regels.push('h'); + } + regels.push('f'); + } else { + if (geo.punten.length < 2) return null; + regels.push(`${f(geo.breedtes[0] ?? ann.lineWidth ?? 2)} w`, '1 J', '1 j', `${r} ${g} ${b} RG`); + const streep = inktStreepPatroon(ann.borderStyle); + if (streep) regels.push(`[${streep.join(' ')}] 0 d`); + geo.punten.forEach((p, i) => regels.push(`${f(X(p.x))} ${f(Y(p.y))} ${i === 0 ? 'm' : 'l'}`)); + regels.push('S'); + } + return regels.join('\n') + '\n'; +} diff --git a/open-pdf-studio/js/annotations/pressure-ink.test.mjs b/open-pdf-studio/js/annotations/pressure-ink.test.mjs new file mode 100644 index 000000000..f5d5164df --- /dev/null +++ b/open-pdf-studio/js/annotations/pressure-ink.test.mjs @@ -0,0 +1,331 @@ +// Drukgevoelige inkt (#385): breedte uit druk, gladmaken, de gevulde omtrek +// (geen uitschieters of gaten bij scherpe hoeken, ronde kappen), de +// coalesced monsters, palmafwijzing en de terugval van de muis (vaste +// breedte). Pure geometrie: geen app-modules, geen zwaar werk. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + breedteUitDruk, klemDruk, klemMinBreedte, klemGladheid, drukReeksVoorOpslag, inktEigenschappen, + heeftDruk, isGladdeInkt, breedtesVan, gladFilter, catmullRom, gladdeStreek, omtrekRingen, + inktGeometrie, inktAppearanceInhoud, inktStreepPatroon, monstersVanEvent, maakPalmFilter, aanwijzerMetDruk, +} from './pressure-ink.js'; + +const bijna = (a, b, tol = 1e-9) => Math.abs(a - b) <= tol; + +/** Afstand van punt p tot de polylijn. */ +function afstandTotLijn(p, lijn) { + if (lijn.length === 1) return Math.hypot(p.x - lijn[0].x, p.y - lijn[0].y); + let best = Infinity; + for (let i = 0; i < lijn.length - 1; i++) { + const a = lijn[i], b = lijn[i + 1]; + const dx = b.x - a.x, dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0; + best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))); + } + return best; +} + +/** Windingsgetal van p over alle ringen samen (nonzero: ≠ 0 is gevuld). */ +function winding(p, ringen) { + let w = 0; + for (const ring of ringen) { + for (let i = 0; i < ring.length; i++) { + const a = ring[i], b = ring[(i + 1) % ring.length]; + const kruis = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y); + if (a.y <= p.y) { if (b.y > p.y && kruis > 0) w++; } + else if (b.y <= p.y && kruis < 0) w--; + } + } + return w; +} + +function geldigeRingen(ringen) { + for (const ring of ringen) { + assert.ok(ring.length >= 3, 'een ring heeft minstens drie punten'); + for (const p of ring) assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), 'geen NaN/Infinity'); + } +} + +/** Geen uitschieter: elk omtrekpunt ligt binnen de halve breedte van de middellijn. */ +function geenUitschieters(ringen, lijn, maxHalf) { + for (const ring of ringen) { + for (const p of ring) { + const d = afstandTotLijn(p, lijn); + assert.ok(d <= maxHalf + 1e-6, `omtrekpunt (${p.x.toFixed(2)}, ${p.y.toFixed(2)}) ligt ${d.toFixed(3)} van de lijn (max ${maxHalf})`); + } + } +} + +/** Elk punt van de middellijn (en tussenpunten) is gevuld. */ +function middellijnGevuld(ringen, lijn) { + for (let i = 0; i < lijn.length - 1; i++) { + for (const t of [0, 0.25, 0.5, 0.75]) { + const p = { x: lijn[i].x + (lijn[i + 1].x - lijn[i].x) * t, y: lijn[i].y + (lijn[i + 1].y - lijn[i].y) * t }; + assert.notEqual(winding(p, ringen), 0, `middellijn bij segment ${i}, t=${t} is niet gevuld (gat)`); + } + } + assert.notEqual(winding(lijn[lijn.length - 1], ringen), 0, 'eindpunt gevuld'); +} + +/** Rond elk punt van de middellijn is de streek gevuld (geen gat binnen de breedte). */ +function lichaamGevuld(ringen, lijn, breedtes, deel = 0.6) { + lijn.forEach((c, i) => { + const r = (breedtes[i] / 2) * deel; + for (let k = 0; k < 8; k++) { + const a = (k / 8) * Math.PI * 2; + const p = { x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r }; + assert.notEqual(winding(p, ringen), 0, `gat bij punt ${i} richting ${k}`); + } + }); +} + +// ── Breedte uit druk ──────────────────────────────────────────────────────── + +test('breedte uit druk: min bij 0, max bij 1, lineair ertussen, geklemd', () => { + assert.equal(breedteUitDruk(0, 10, 30), 3); + assert.equal(breedteUitDruk(1, 10, 30), 10); + assert.ok(bijna(breedteUitDruk(0.5, 10, 30), 6.5)); + assert.equal(breedteUitDruk(2, 10, 30), 10, 'druk boven 1 wordt 1'); + assert.equal(breedteUitDruk(-1, 10, 30), 3, 'druk onder 0 wordt 0'); + assert.ok(bijna(breedteUitDruk(NaN, 10, 30), 6.5), 'onbekende druk: de helft'); + assert.equal(breedteUitDruk(0, 10, 100), 10, 'min 100 %: vaste breedte'); + assert.equal(breedteUitDruk(0, 10, 0), 0.5, 'min wordt nooit kleiner dan 5 %'); + assert.equal(breedteUitDruk(1, 0, 30), 2, 'zonder geldige lijndikte: 2'); + assert.equal(klemDruk(undefined), 0.5); + assert.equal(klemMinBreedte('abc'), 30); + assert.equal(klemGladheid(150), 100); + assert.equal(klemGladheid(-3), 0); +}); + +test('drukreeks: zonder variatie (muis, pen zonder sensor) geen druk; anders afgerond', () => { + assert.equal(drukReeksVoorOpslag([0.5, 0.5, 0.5]), null); + assert.equal(drukReeksVoorOpslag([0, 0, 0.01]), null); + assert.deepEqual(drukReeksVoorOpslag([0.12345, 0.9, 1.4]), [0.123, 0.9, 1]); + assert.equal(drukReeksVoorOpslag([0.1, 0.9], 3), null, 'aantal moet bij de punten passen'); + assert.equal(drukReeksVoorOpslag(null), null); +}); + +test('muis: vaste breedte — geen druk in het model, elke breedte de lijndikte', () => { + assert.equal(aanwijzerMetDruk('mouse'), false); + assert.equal(aanwijzerMetDruk('touch'), false); + assert.equal(aanwijzerMetDruk('pen'), true); + const prefs = { drawSmoothing: 40, drawPressureMinWidth: 25 }; + // De muis meldt 0,5 bij een ingedrukte knop; ook met variatie telt hij niet. + const muis = inktEigenschappen(prefs, { metDruk: false, drukken: [0.5, 0.2, 0.9] }, 3); + assert.deepEqual(muis, { inkSmoothing: 40 }); + const ann = { type: 'draw', path: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 20, y: 5 }], lineWidth: 4, ...muis }; + assert.equal(heeftDruk(ann), false); + assert.deepEqual(breedtesVan(ann), [4, 4, 4]); + const geo = inktGeometrie(ann); + assert.equal(geo.variabel, false, 'een muisstreek wordt een lijn, geen vlak'); + assert.ok(geo.breedtes.every((w) => w === 4)); + assert.match(inktAppearanceInhoud(ann, (x) => x, (y) => 100 - y, [0, 0, 0]), /^4 w$/m); + + const pen = inktEigenschappen(prefs, { metDruk: true, drukken: [0.1, 0.5, 0.9] }, 3); + assert.deepEqual(pen, { inkSmoothing: 40, pressures: [0.1, 0.5, 0.9], inkMinWidth: 25 }); + const penZonderSensor = inktEigenschappen(prefs, { metDruk: true, drukken: [0.5, 0.5, 0.5] }, 3); + assert.equal(penZonderSensor.pressures, undefined, 'pen zonder druksensor: vaste breedte'); +}); + +test('druk uit (inkPressure false) of niet passend: vaste breedte', () => { + const path = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 20, y: 0 }]; + assert.equal(heeftDruk({ path, pressures: [0.1, 0.9, 0.5] }), true); + assert.equal(heeftDruk({ path, pressures: [0.1, 0.9, 0.5], inkPressure: false }), false); + assert.equal(heeftDruk({ path, pressures: [0.1, 0.9] }), false); + assert.deepEqual(breedtesVan({ path, pressures: [0, 1, 0.5], inkMinWidth: 50, lineWidth: 8 }), [4, 8, 6]); + assert.equal(isGladdeInkt({ path }), false, 'oude streek: de oude kale polylijn'); + assert.equal(isGladdeInkt({ path, inkSmoothing: 0 }), false); + assert.equal(isGladdeInkt({ path, inkSmoothing: 30 }), true); +}); + +// ── Gladmaken ─────────────────────────────────────────────────────────────── + +test('gladfilter: begin en eind blijven, ruis neemt af, sterkte 0 doet niets', () => { + const ruis = Array.from({ length: 60 }, (_, i) => (i % 2 ? 1 : -1) + i * 0.1); + const glad = gladFilter(ruis, 70); + assert.equal(glad[0], ruis[0]); + assert.equal(glad[glad.length - 1], ruis[ruis.length - 1]); + const ruwheid = (r) => r.slice(2).reduce((s, v, i) => s + (v - 2 * r[i + 1] + r[i]) ** 2, 0); + assert.ok(ruwheid(glad) < ruwheid(ruis) * 0.1, 'zigzag sterk gedempt'); + assert.deepEqual(gladFilter(ruis, 0), ruis); + // Geen vertraging: een rechte lijn blijft (binnenin) op zijn plaats. + const recht = Array.from({ length: 20 }, (_, i) => i); + gladFilter(recht, 80).forEach((v, i) => assert.ok(Math.abs(v - i) < 1.5, `geen naijlen bij ${i}`)); +}); + +test('catmull-rom: gaat door elk punt, verfijnt, waarden lineair mee', () => { + const punten = [{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 20, y: 0 }, { x: 30, y: 10 }]; + const { punten: uit, waarden } = catmullRom(punten, [1, 2, 3, 4], { stap: 1 }); + assert.ok(uit.length > 30, 'verfijnd'); + for (const p of punten) assert.ok(uit.some((q) => bijna(q.x, p.x, 1e-9) && bijna(q.y, p.y, 1e-9)), 'door elk invoerpunt'); + assert.equal(waarden[0], 1); + assert.equal(waarden[waarden.length - 1], 4); + for (const q of uit) assert.ok(q.y > -3 && q.y < 13, 'geen grote doorschieter'); +}); + +test('gladde streek: zonder gladheid de punten zelf, met gladheid hetzelfde begin en eind', () => { + const path = [{ x: 0, y: 0 }, { x: 5, y: 1 }, { x: 10, y: -1 }, { x: 15, y: 1 }, { x: 20, y: 0 }]; + const kaal = gladdeStreek(path, [2, 2, 2, 2, 2], 0); + assert.deepEqual(kaal.punten, path); + const glad = gladdeStreek(path, [2, 2, 2, 2, 2], 60); + assert.deepEqual(glad.punten[0], path[0]); + assert.deepEqual(glad.punten[glad.punten.length - 1], path[path.length - 1]); + assert.ok(Math.max(...glad.punten.map((p) => Math.abs(p.y))) < 1, 'de zigzag is afgevlakt'); +}); + +// ── Omtrek ────────────────────────────────────────────────────────────────── + +test('omtrek van een rechte streek: één ring, ronde kappen voorbij de eindpunten', () => { + const lijn = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 20, y: 0 }]; + const ringen = omtrekRingen(lijn, [2, 6, 2]); + geldigeRingen(ringen); + assert.equal(ringen.length, 1); + geenUitschieters(ringen, lijn, 3); + middellijnGevuld(ringen, lijn); + const xs = ringen[0].map((p) => p.x); + assert.ok(bijna(Math.min(...xs), -1, 0.05), 'beginkap: halve breedte voor het beginpunt'); + assert.ok(bijna(Math.max(...xs), 21, 0.05), 'eindkap: halve breedte na het eindpunt'); + const ys = ringen[0].map((p) => p.y); + assert.ok(bijna(Math.max(...ys), 3, 1e-9), 'in het midden de volle breedte'); + assert.equal(winding({ x: 10, y: 3.5 }, ringen), 0, 'naast de streek leeg'); +}); + +test('scherpe hoek (haarspeld en V): geen uitschieters, geen gaten', () => { + for (const lijn of [ + [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 0, y: 1 }], // haarspeld, bijna 180° + [{ x: 0, y: 0 }, { x: 20, y: 0 }, { x: 0, y: 8 }], // spitse V + [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }], // twee rechte hoeken + ]) { + const ringen = omtrekRingen(lijn, lijn.map(() => 8)); + geldigeRingen(ringen); + geenUitschieters(ringen, lijn, 4); + middellijnGevuld(ringen, lijn); + lichaamGevuld(ringen, lijn, lijn.map(() => 8)); + } +}); + +test('krappe bocht smaller dan de streek en een zigzag: geen uitschieters, geen gaten', () => { + const spiraal = Array.from({ length: 80 }, (_, i) => ({ x: Math.cos(i / 4) * (1 + i / 20), y: Math.sin(i / 4) * (1 + i / 20) })); + const breed = spiraal.map((_, i) => 4 + 4 * Math.sin(i / 7) ** 2); + const ringen = omtrekRingen(spiraal, breed); + geldigeRingen(ringen); + geenUitschieters(ringen, spiraal, 4); + middellijnGevuld(ringen, spiraal); + lichaamGevuld(ringen, spiraal, breed); + + const zigzag = Array.from({ length: 30 }, (_, i) => ({ x: i * 1.2, y: i % 2 ? 3 : 0 })); + const zr = omtrekRingen(zigzag, zigzag.map((_, i) => 1 + (i % 5))); + geldigeRingen(zr); + geenUitschieters(zr, zigzag, 2.5); + middellijnGevuld(zr, zigzag); + lichaamGevuld(zr, zigzag, zigzag.map((_, i) => 1 + (i % 5))); +}); + +test('breedtesprong (dikke punt naast dunne): de grote cirkel, geen omgeslagen ring', () => { + const lijn = [{ x: 0, y: 0 }, { x: 1, y: 0 }]; + const ringen = omtrekRingen(lijn, [0.5, 10]); + geldigeRingen(ringen); + geenUitschieters(ringen, lijn, 5); + middellijnGevuld(ringen, lijn); +}); + +test('één punt (een tik) wordt een stip; lege invoer niets', () => { + const ringen = omtrekRingen([{ x: 5, y: 5 }], [4]); + assert.equal(ringen.length, 1); + geenUitschieters(ringen, [{ x: 5, y: 5 }], 2); + assert.notEqual(winding({ x: 5, y: 5 }, ringen), 0); + assert.deepEqual(omtrekRingen([], []), []); + assert.deepEqual(omtrekRingen([{ x: 1, y: 1 }, { x: 1, y: 1 }], [3, 3]).length, 1, 'dubbele punten: één stip'); +}); + +test('alle ringen hebben dezelfde omloop (nonzero voegt ze samen)', () => { + const lijn = [{ x: 0, y: 0 }, { x: 30, y: 0 }, { x: 0, y: 2 }, { x: 30, y: 4 }]; + const ringen = omtrekRingen(lijn, [3, 6, 3, 6]); + assert.ok(ringen.length >= 2); + const oppervlak = (r) => r.reduce((s, p, i) => { const q = r[(i + 1) % r.length]; return s + p.x * q.y - q.x * p.y; }, 0) / 2; + const tekens = ringen.map((r) => Math.sign(oppervlak(r))); + assert.ok(tekens.every((t) => t === tekens[0]), `omloop ${tekens}`); +}); + +test('inktgeometrie van een pen-streek: gevuld, binnen de breedte, cache bij dezelfde streek', () => { + const path = Array.from({ length: 40 }, (_, i) => ({ x: i * 3, y: Math.sin(i / 5) * 12 + (i % 2 ? 0.4 : -0.4) })); + const ann = { type: 'draw', path, pressures: path.map((_, i) => i / 39), lineWidth: 6, inkMinWidth: 20, inkSmoothing: 60 }; + const geo = inktGeometrie(ann); + assert.equal(geo.variabel, true); + geldigeRingen(geo.ringen); + geenUitschieters(geo.ringen, geo.punten, 3); + middellijnGevuld(geo.ringen, geo.punten); + lichaamGevuld(geo.ringen, geo.punten, geo.breedtes); + assert.ok(geo.breedtes[0] < geo.breedtes[geo.breedtes.length - 1], 'dun begin, dik eind'); + assert.equal(inktGeometrie(ann), geo, 'zelfde streek: uit de cache'); + ann.path = path.map((p) => ({ x: p.x + 1, y: p.y })); + assert.notEqual(inktGeometrie(ann), geo, 'verschoven: opnieuw berekend'); + const inhoud = inktAppearanceInhoud(ann, (x) => x, (y) => 500 - y, [1, 0, 0]); + assert.match(inhoud, /^1 0 0 rg$/m); + assert.match(inhoud, /^h$/m); + assert.match(inhoud, /^f$/m); +}); + +test('gestreepte streek: getrokken middellijn met streeppatroon in de appearance, ook met druk', () => { + const path = Array.from({ length: 20 }, (_, i) => ({ x: i * 6, y: Math.sin(i / 4) * 10 })); + const pressures = path.map((_, i) => 0.1 + 0.9 * (i / 19)); + const pen = { type: 'draw', path, pressures, lineWidth: 6, inkMinWidth: 20, inkSmoothing: 50, borderStyle: 'dashed' }; + const geo = inktGeometrie(pen); + assert.equal(geo.variabel, false, 'een vulling draagt geen streeppatroon: terug naar een getrokken lijn'); + assert.ok(geo.breedtes.every((w) => w === 6), 'volle breedte langs de hele streek'); + const inhoud = inktAppearanceInhoud(pen, (x) => x, (y) => 500 - y, [0, 0, 1]); + assert.match(inhoud, /^\[3 4\] 0 d$/m, 'streeppatroon in de appearance'); + assert.match(inhoud, /^6 w$/m); + assert.match(inhoud, /\nS\n$/); + assert.doesNotMatch(inhoud, /^f$/m); + // Muisstreek met een ander patroon. + const muis = { type: 'draw', path, lineWidth: 2, inkSmoothing: 50, borderStyle: 'long-dash-dot' }; + assert.match(inktAppearanceInhoud(muis, (x) => x, (y) => y, [0, 0, 0]), /^\[20 10 2 10\] 0 d$/m); + // Doorgetrokken: geen streepoperator, pen blijft een gevulde omtrek. + assert.doesNotMatch(inktAppearanceInhoud({ ...muis, borderStyle: 'solid' }, (x) => x, (y) => y, [0, 0, 0]), / d$/m); + const vast = { ...pen, borderStyle: 'solid' }; + assert.equal(inktGeometrie(vast).variabel, true); + // Wisselen van lijnstijl op dezelfde streek ververst de cache. + pen.borderStyle = 'solid'; + assert.equal(inktGeometrie(pen).variabel, true, 'lijnstijl zit in de cachesleutel'); +}); + +test('streeppatronen gelijk aan het scherm (applyBorderStyle)', async () => { + const { applyBorderStyle } = await import('./rendering/decorations.js'); + for (const stijl of ['solid', 'dashed', 'dotted', 'dash-dot', 'dash-dot-dot', 'long-dash', 'long-dash-dot', 'long-dash-dot-dot']) { + let patroon = null; + applyBorderStyle({ setLineDash: (d) => { patroon = d; } }, stijl); + assert.deepEqual(inktStreepPatroon(stijl) || [], patroon, stijl); + } +}); + +// ── Invoer ────────────────────────────────────────────────────────────────── + +test('coalesced monsters: alle tussenliggende metingen, anders het event zelf', () => { + const e = { + clientX: 30, clientY: 3, pressure: 0.7, + getCoalescedEvents: () => [ + { clientX: 10, clientY: 1, pressure: 0.3 }, + { clientX: 20, clientY: 2, pressure: 0.5 }, + { clientX: 30, clientY: 3, pressure: 0.7 }, + ], + }; + assert.deepEqual(monstersVanEvent(e).map((m) => m.pressure), [0.3, 0.5, 0.7]); + assert.deepEqual(monstersVanEvent({ clientX: 1, clientY: 2, pressure: 0.4 }), [{ clientX: 1, clientY: 2, pressure: 0.4 }]); + assert.equal(monstersVanEvent({ clientX: 1, clientY: 2, getCoalescedEvents: () => [] }).length, 1, 'lege lijst: het event zelf'); + assert.equal(monstersVanEvent({ clientX: 1, clientY: 2, getCoalescedEvents: () => { throw new Error('x'); } }).length, 1); +}); + +test('palmafwijzing: aanraking telt niet zolang de pen in de buurt is, of nooit met "alleen pen"', () => { + const palm = maakPalmFilter({ nawerking: 500 }); + const vinger = { pointerType: 'touch' }; + assert.equal(palm.weiger(vinger, 0), false, 'zonder pen: een vinger mag tekenen'); + palm.noteer({ pointerType: 'pen' }, 1000); + assert.equal(palm.weiger(vinger, 1200), true, 'pen zweeft/schrijft: de palm telt niet'); + assert.equal(palm.weiger(vinger, 1600), false, 'pen weg: de vinger weer wel'); + assert.equal(palm.weiger({ pointerType: 'mouse' }, 1200), false, 'een muis nooit'); + assert.equal(palm.weiger({ pointerType: 'pen' }, 1200), false, 'de pen zelf nooit'); + assert.equal(palm.weiger(vinger, 99999, { alleenPen: true }), true, 'alleen pen'); +}); diff --git a/open-pdf-studio/js/annotations/rendering.js b/open-pdf-studio/js/annotations/rendering.js index cb588c5f8..74c8c9132 100644 --- a/open-pdf-studio/js/annotations/rendering.js +++ b/open-pdf-studio/js/annotations/rendering.js @@ -9,6 +9,7 @@ import { drawPolygonShape, drawCloudShape, buildPolygonPath, buildPolygonPointsP import { tekstvakKnipvlak } from './rendering/textbox-layout.js'; import { drawArrowheadOnCanvas, applyBorderStyle, drawDimensionLineEnding } from './rendering/decorations.js'; import { catmullRomSpline } from '../tools/tools/spline-tool.js'; +import { isGladdeInkt, inktGeometrie } from './pressure-ink.js'; import { catmullRomToBezier, splineArrowEndTangent } from './spline-arrow-geometry.js'; import { calloutLeiderKoppen } from './callout-koppen.js'; import { koppenVan } from '../pdf/lijnkoppen.js'; @@ -578,6 +579,31 @@ export function drawAnnotation(ctx, annotation, snippetBitmaps = null) { switch (annotation.type) { case 'draw': ctx.strokeStyle = strokeColor; + // Drukgevoelige / gladde inkt (#385): gevulde omtrek bij variabele + // breedte, anders de gladde middellijn. De schermlijndikte (thinLw) + // schaalt de hele streek mee. + if (isGladdeInkt(annotation)) { + const eigen = annotation.lineWidth > 0 ? annotation.lineWidth : 0; + const geo = inktGeometrie(annotation, { breedteFactor: eigen ? lw / eigen : 1 }); + if (geo.variabel) { + ctx.fillStyle = strokeColor; + ctx.beginPath(); + for (const ring of geo.ringen) { + ring.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.closePath(); + } + ctx.fill('nonzero'); + } else if (geo.punten.length > 1) { + applyBorderStyle(ctx, annotation.borderStyle); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + geo.punten.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.stroke(); + ctx.setLineDash([]); + } + break; + } applyBorderStyle(ctx, annotation.borderStyle); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; diff --git a/open-pdf-studio/js/core/constants.ts b/open-pdf-studio/js/core/constants.ts index 3256bc66f..d76c759c6 100644 --- a/open-pdf-studio/js/core/constants.ts +++ b/open-pdf-studio/js/core/constants.ts @@ -180,6 +180,13 @@ export const DEFAULT_PREFERENCES: Preferences = { drawStrokeColor: '#FF0000', drawLineWidth: 2, drawOpacity: 100, + // Drukgevoelige inkt (#385): pendruk stuurt de breedte (een muis blijft + // vast), minimale breedte in % van de lijndikte, gladheid 0..100, en + // alleen de pen tekent (aanraking genegeerd). + drawPressure: true, + drawPressureMinWidth: 30, + drawSmoothing: 50, + drawPenOnly: false, // Line defaults lineStrokeColor: '#FF0000', diff --git a/open-pdf-studio/js/core/preferences.js b/open-pdf-studio/js/core/preferences.js index 7b43067ba..92466d70f 100644 --- a/open-pdf-studio/js/core/preferences.js +++ b/open-pdf-studio/js/core/preferences.js @@ -274,6 +274,13 @@ export function setAsDefaultStyle(annotation) { // Zijdematen van een meetvlak (#536): aan of uit, standaard uit. if (m.edgeLengths) prefs[p + m.edgeLengths] = annotation.measureShowEdgeLengths === true; + // Vrije hand (#385): pendruk, minimale breedte en gladheid. + if (m.ink) { + prefs[p + 'Pressure'] = annotation.inkPressure !== false; + if (annotation.inkMinWidth != null) prefs[p + 'PressureMinWidth'] = annotation.inkMinWidth; + if (annotation.inkSmoothing != null) prefs[p + 'Smoothing'] = annotation.inkSmoothing; + } + savePreferences(); updateStatusMessage('Style set as default'); } @@ -335,6 +342,14 @@ export function applyDefaultStyle(annotation) { if (m.edgeLengths) annotation.measureShowEdgeLengths = prefs[p + m.edgeLengths] === true; + // Vrije hand (#385). De druk zelf hoort bij de streek; hier alleen of hij + // meetelt, de minimale breedte en de gladheid. + if (m.ink) { + annotation.inkPressure = prefs[p + 'Pressure'] === false ? false : undefined; + if (prefs[p + 'PressureMinWidth'] != null) annotation.inkMinWidth = prefs[p + 'PressureMinWidth']; + if (prefs[p + 'Smoothing'] != null) annotation.inkSmoothing = prefs[p + 'Smoothing']; + } + annotation.modifiedAt = new Date().toISOString(); } diff --git a/open-pdf-studio/js/core/stijl-mapping.js b/open-pdf-studio/js/core/stijl-mapping.js index fbc84a3ed..68c56091b 100644 --- a/open-pdf-studio/js/core/stijl-mapping.js +++ b/open-pdf-studio/js/core/stijl-mapping.js @@ -11,7 +11,7 @@ export function getStyleMapping(type) { const hasHatch = ['box', 'circle', 'polygon', 'cloud'].includes(type); switch (type) { case 'draw': - return { prefix: 'draw', stroke: 'StrokeColor', width: 'LineWidth', opacity: 'Opacity' }; + return { prefix: 'draw', stroke: 'StrokeColor', width: 'LineWidth', opacity: 'Opacity', ink: true }; case 'highlight': return { prefix: 'highlight', color: 'Color', opacity: 'Opacity' }; case 'line': diff --git a/open-pdf-studio/js/i18n/locales/ar/preferences.json b/open-pdf-studio/js/i18n/locales/ar/preferences.json index 9f71b594c..00fad5bd0 100644 --- a/open-pdf-studio/js/i18n/locales/ar/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ar/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "سهم مفتوح (معكوس)", "headClosedReversed": "سهم مغلق (معكوس)", "headSlash": "شرطة مائلة", - "polylineDefaults": "إعدادات الخط المتعدد الافتراضية" + "polylineDefaults": "إعدادات الخط المتعدد الافتراضية", + "smoothing": "التنعيم", + "pressure": "ضغط القلم", + "pressureHint": "يغيّر ضغط القلم عرض الخط (تحتفظ خطوط الفأرة بعرض ثابت)", + "pressureMinWidth": "أدنى عرض (%)", + "penOnly": "القلم فقط (تجاهل اللمس)", + "penOnlyHint": "لا يرسم اللمس بأداة الرسم الحر؛ مفيد ضد ملامسة راحة اليد أثناء الكتابة بالقلم" }, "shapes": { "rectangleDefaults": "إعدادات المستطيل الافتراضية", diff --git a/open-pdf-studio/js/i18n/locales/ar/properties.json b/open-pdf-studio/js/i18n/locales/ar/properties.json index 0f9e75b5e..4fa562e43 100644 --- a/open-pdf-studio/js/i18n/locales/ar/properties.json +++ b/open-pdf-studio/js/i18n/locales/ar/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "أفقي متباعد" } }, + "ink": { + "title": "الحبر", + "pressure": "ضغط القلم", + "pressureHint": "يغيّر ضغط القلم عرض الخط (تحتفظ خطوط الفأرة بعرض ثابت)", + "minWidth": "أدنى عرض (%)", + "minWidthHint": "العرض عند أخف ضغط للقلم، كنسبة مئوية من سماكة الخط", + "smoothing": "التنعيم", + "smoothingHint": "قوة تنعيم الخط (0 = بلا تنعيم)" + }, "attachment": { "file": "الملف", "size": "الحجم", diff --git a/open-pdf-studio/js/i18n/locales/bg/preferences.json b/open-pdf-studio/js/i18n/locales/bg/preferences.json index 0096db3f2..3b13e457b 100644 --- a/open-pdf-studio/js/i18n/locales/bg/preferences.json +++ b/open-pdf-studio/js/i18n/locales/bg/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Отворена стрелка (обърната)", "headClosedReversed": "Затворена стрелка (обърната)", "headSlash": "Наклонена черта", - "polylineDefaults": "Стойности по подразбиране за полилиния" + "polylineDefaults": "Стойности по подразбиране за полилиния", + "smoothing": "Изглаждане", + "pressure": "Натиск на писалката", + "pressureHint": "Натискът на писалката променя дебелината на линията (линиите с мишка запазват постоянна дебелина)", + "pressureMinWidth": "Минимална ширина (%)", + "penOnly": "Само писалка (без докосване)", + "penOnlyHint": "Докосването не рисува с инструмента за свободна ръка; полезно срещу допир на дланта при писане с писалка" }, "shapes": { "rectangleDefaults": "Стойности по подразбиране за правоъгълник", diff --git a/open-pdf-studio/js/i18n/locales/bg/properties.json b/open-pdf-studio/js/i18n/locales/bg/properties.json index f0fc27dad..5c5b1654c 100644 --- a/open-pdf-studio/js/i18n/locales/bg/properties.json +++ b/open-pdf-studio/js/i18n/locales/bg/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Хоризонтална щриховка рядка" } }, + "ink": { + "title": "Мастило", + "pressure": "Натиск на писалката", + "pressureHint": "Натискът на писалката променя дебелината на линията (линиите с мишка запазват постоянна дебелина)", + "minWidth": "Минимална ширина (%)", + "minWidthHint": "Ширина при най-лекия натиск, като процент от дебелината на линията", + "smoothing": "Изглаждане", + "smoothingHint": "Сила на изглаждането на линията (0 = без)" + }, "attachment": { "file": "Файл", "size": "Размер", diff --git a/open-pdf-studio/js/i18n/locales/bn/preferences.json b/open-pdf-studio/js/i18n/locales/bn/preferences.json index fba472251..1fba6dbc0 100644 --- a/open-pdf-studio/js/i18n/locales/bn/preferences.json +++ b/open-pdf-studio/js/i18n/locales/bn/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "খোলা তীর (বিপরীত)", "headClosedReversed": "বন্ধ তীর (বিপরীত)", "headSlash": "স্ল্যাশ", - "polylineDefaults": "পলিলাইন ডিফল্ট" + "polylineDefaults": "পলিলাইন ডিফল্ট", + "smoothing": "মসৃণকরণ", + "pressure": "কলমের চাপ", + "pressureHint": "কলমের চাপে রেখার প্রস্থ বদলায় (মাউসের রেখার প্রস্থ স্থির থাকে)", + "pressureMinWidth": "ন্যূনতম প্রস্থ (%)", + "penOnly": "শুধু কলম (স্পর্শ উপেক্ষা)", + "penOnlyHint": "স্পর্শে ফ্রিহ্যান্ড টুল দিয়ে আঁকা হয় না; কলম দিয়ে লেখার সময় হাতের তালুর স্পর্শ এড়াতে উপযোগী" }, "shapes": { "rectangleDefaults": "আয়তক্ষেত্র ডিফল্ট", diff --git a/open-pdf-studio/js/i18n/locales/bn/properties.json b/open-pdf-studio/js/i18n/locales/bn/properties.json index 3cc63d2f7..35511243b 100644 --- a/open-pdf-studio/js/i18n/locales/bn/properties.json +++ b/open-pdf-studio/js/i18n/locales/bn/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "অনুভূমিক বিরল" } }, + "ink": { + "title": "কালি", + "pressure": "কলমের চাপ", + "pressureHint": "কলমের চাপে রেখার প্রস্থ বদলায় (মাউসের রেখার প্রস্থ স্থির থাকে)", + "minWidth": "ন্যূনতম প্রস্থ (%)", + "minWidthHint": "সবচেয়ে হালকা চাপে প্রস্থ, রেখার পুরুত্বের শতাংশ হিসেবে", + "smoothing": "মসৃণকরণ", + "smoothingHint": "রেখা মসৃণ করার মাত্রা (0 = নেই)" + }, "attachment": { "file": "ফাইল", "size": "আকার", diff --git a/open-pdf-studio/js/i18n/locales/ca/preferences.json b/open-pdf-studio/js/i18n/locales/ca/preferences.json index 11517d009..64b5632b3 100644 --- a/open-pdf-studio/js/i18n/locales/ca/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ca/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Fletxa oberta (invertida)", "headClosedReversed": "Fletxa tancada (invertida)", "headSlash": "Barra", - "polylineDefaults": "Valors per defecte de polilínia" + "polylineDefaults": "Valors per defecte de polilínia", + "smoothing": "Suavitzat", + "pressure": "Pressió del llapis", + "pressureHint": "La pressió del llapis varia el gruix del traç (els traços amb el ratolí mantenen un gruix constant)", + "pressureMinWidth": "Amplada mínima (%)", + "penOnly": "Només llapis (ignora el tacte)", + "penOnlyHint": "El tacte no dibuixa amb l'eina a mà alçada; útil contra el contacte del palmell en escriure amb el llapis" }, "shapes": { "rectangleDefaults": "Valors per defecte del rectangle", diff --git a/open-pdf-studio/js/i18n/locales/ca/properties.json b/open-pdf-studio/js/i18n/locales/ca/properties.json index 68571756c..b81568b27 100644 --- a/open-pdf-studio/js/i18n/locales/ca/properties.json +++ b/open-pdf-studio/js/i18n/locales/ca/properties.json @@ -850,6 +850,15 @@ "inb-horizontaal-breed": "Horitzontal ample" } }, + "ink": { + "title": "Tinta", + "pressure": "Pressió del llapis", + "pressureHint": "La pressió del llapis varia el gruix del traç (els traços amb el ratolí mantenen un gruix constant)", + "minWidth": "Amplada mínima (%)", + "minWidthHint": "Amplada amb la pressió més suau, com a percentatge del gruix de línia", + "smoothing": "Suavitzat", + "smoothingHint": "Intensitat del suavitzat del traç (0 = cap)" + }, "attachment": { "file": "Fitxer", "size": "Mida", diff --git a/open-pdf-studio/js/i18n/locales/cs/preferences.json b/open-pdf-studio/js/i18n/locales/cs/preferences.json index 08b84ee07..0a7a783c2 100644 --- a/open-pdf-studio/js/i18n/locales/cs/preferences.json +++ b/open-pdf-studio/js/i18n/locales/cs/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Otevřená šipka (obrácená)", "headClosedReversed": "Uzavřená šipka (obrácená)", "headSlash": "Lomítko", - "polylineDefaults": "Výchozí hodnoty lomené čáry" + "polylineDefaults": "Výchozí hodnoty lomené čáry", + "smoothing": "Vyhlazení", + "pressure": "Přítlak pera", + "pressureHint": "Přítlak pera mění šířku tahu (tahy myší mají stálou šířku)", + "pressureMinWidth": "Minimální šířka (%)", + "penOnly": "Pouze pero (ignorovat dotyk)", + "penOnlyHint": "Dotyk nekreslí nástrojem volné ruky; užitečné proti dotyku dlaně při psaní perem" }, "shapes": { "rectangleDefaults": "Výchozí hodnoty obdélníku", diff --git a/open-pdf-studio/js/i18n/locales/cs/properties.json b/open-pdf-studio/js/i18n/locales/cs/properties.json index 68d8c4764..4c86ccb15 100644 --- a/open-pdf-studio/js/i18n/locales/cs/properties.json +++ b/open-pdf-studio/js/i18n/locales/cs/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vodorovná šrafa řídká" } }, + "ink": { + "title": "Inkoust", + "pressure": "Přítlak pera", + "pressureHint": "Přítlak pera mění šířku tahu (tahy myší mají stálou šířku)", + "minWidth": "Minimální šířka (%)", + "minWidthHint": "Šířka při nejlehčím přítlaku, v procentech tloušťky čáry", + "smoothing": "Vyhlazení", + "smoothingHint": "Síla vyhlazení tahu (0 = žádné)" + }, "attachment": { "file": "Soubor", "size": "Velikost", diff --git a/open-pdf-studio/js/i18n/locales/da/preferences.json b/open-pdf-studio/js/i18n/locales/da/preferences.json index 3246b7fe6..d95ae86c5 100644 --- a/open-pdf-studio/js/i18n/locales/da/preferences.json +++ b/open-pdf-studio/js/i18n/locales/da/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Åben pil (omvendt)", "headClosedReversed": "Lukket pil (omvendt)", "headSlash": "Skråstreg", - "polylineDefaults": "Polylinjestandarder" + "polylineDefaults": "Polylinjestandarder", + "smoothing": "Udglatning", + "pressure": "Pentryk", + "pressureHint": "Pentrykket ændrer stregbredden (musestreger beholder en fast bredde)", + "pressureMinWidth": "Mindste bredde (%)", + "penOnly": "Kun pen (ignorer berøring)", + "penOnlyHint": "Berøring tegner ikke med frihåndsværktøjet; nyttigt mod håndfladekontakt, når du skriver med pen" }, "shapes": { "rectangleDefaults": "Rektangelstandarder", diff --git a/open-pdf-studio/js/i18n/locales/da/properties.json b/open-pdf-studio/js/i18n/locales/da/properties.json index 5bbc9d367..34deb011d 100644 --- a/open-pdf-studio/js/i18n/locales/da/properties.json +++ b/open-pdf-studio/js/i18n/locales/da/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vandret åben" } }, + "ink": { + "title": "Blæk", + "pressure": "Pentryk", + "pressureHint": "Pentrykket ændrer stregbredden (musestreger beholder en fast bredde)", + "minWidth": "Mindste bredde (%)", + "minWidthHint": "Bredde ved letteste pentryk, i procent af stregtykkelsen", + "smoothing": "Udglatning", + "smoothingHint": "Styrke af stregudglatning (0 = ingen)" + }, "attachment": { "file": "Fil", "size": "Størrelse", diff --git a/open-pdf-studio/js/i18n/locales/de/preferences.json b/open-pdf-studio/js/i18n/locales/de/preferences.json index 08d030e41..85825fff2 100644 --- a/open-pdf-studio/js/i18n/locales/de/preferences.json +++ b/open-pdf-studio/js/i18n/locales/de/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Offener Pfeil (umgekehrt)", "headClosedReversed": "Geschlossener Pfeil (umgekehrt)", "headSlash": "Schrägstrich", - "polylineDefaults": "Polylinien-Standardwerte" + "polylineDefaults": "Polylinien-Standardwerte", + "smoothing": "Glättung", + "pressure": "Stiftdruck", + "pressureHint": "Der Stiftdruck verändert die Strichbreite (Mausstriche behalten eine feste Breite)", + "pressureMinWidth": "Mindestbreite (%)", + "penOnly": "Nur Stift (Berührung ignorieren)", + "penOnlyHint": "Berührung zeichnet nicht mit dem Freihandwerkzeug; hilfreich gegen Handballenkontakt beim Schreiben mit dem Stift" }, "shapes": { "rectangleDefaults": "Rechteck-Standardwerte", diff --git a/open-pdf-studio/js/i18n/locales/de/properties.json b/open-pdf-studio/js/i18n/locales/de/properties.json index 426225cea..f667b2ece 100644 --- a/open-pdf-studio/js/i18n/locales/de/properties.json +++ b/open-pdf-studio/js/i18n/locales/de/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontal weit" } }, + "ink": { + "title": "Tinte", + "pressure": "Stiftdruck", + "pressureHint": "Der Stiftdruck verändert die Strichbreite (Mausstriche behalten eine feste Breite)", + "minWidth": "Mindestbreite (%)", + "minWidthHint": "Breite bei leichtestem Stiftdruck, in Prozent der Linienstärke", + "smoothing": "Glättung", + "smoothingHint": "Stärke der Strichglättung (0 = aus)" + }, "attachment": { "file": "Datei", "size": "Größe", diff --git a/open-pdf-studio/js/i18n/locales/el/preferences.json b/open-pdf-studio/js/i18n/locales/el/preferences.json index f2922cf03..38a99cd8a 100644 --- a/open-pdf-studio/js/i18n/locales/el/preferences.json +++ b/open-pdf-studio/js/i18n/locales/el/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Ανοιχτό βέλος (αντεστραμμένο)", "headClosedReversed": "Κλειστό βέλος (αντεστραμμένο)", "headSlash": "Πλάγια γραμμή", - "polylineDefaults": "Προεπιλογές πολυγραμμής" + "polylineDefaults": "Προεπιλογές πολυγραμμής", + "smoothing": "Εξομάλυνση", + "pressure": "Πίεση γραφίδας", + "pressureHint": "Η πίεση της γραφίδας μεταβάλλει το πάχος της γραμμής (οι γραμμές με ποντίκι έχουν σταθερό πάχος)", + "pressureMinWidth": "Ελάχιστο πλάτος (%)", + "penOnly": "Μόνο γραφίδα (αγνόηση αφής)", + "penOnlyHint": "Η αφή δεν σχεδιάζει με το εργαλείο ελεύθερου σχεδίου· χρήσιμο κατά της επαφής της παλάμης όταν γράφετε με γραφίδα" }, "shapes": { "rectangleDefaults": "Προεπιλογές ορθογωνίου", diff --git a/open-pdf-studio/js/i18n/locales/el/properties.json b/open-pdf-studio/js/i18n/locales/el/properties.json index ab6c0ef56..3bc8a71f1 100644 --- a/open-pdf-studio/js/i18n/locales/el/properties.json +++ b/open-pdf-studio/js/i18n/locales/el/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Οριζόντια διαγράμμιση αραιή" } }, + "ink": { + "title": "Μελάνι", + "pressure": "Πίεση γραφίδας", + "pressureHint": "Η πίεση της γραφίδας μεταβάλλει το πάχος της γραμμής (οι γραμμές με ποντίκι έχουν σταθερό πάχος)", + "minWidth": "Ελάχιστο πλάτος (%)", + "minWidthHint": "Πλάτος στην ελαφρύτερη πίεση, ως ποσοστό του πάχους γραμμής", + "smoothing": "Εξομάλυνση", + "smoothingHint": "Ένταση εξομάλυνσης της γραμμής (0 = καμία)" + }, "attachment": { "file": "Αρχείο", "size": "Μέγεθος", diff --git a/open-pdf-studio/js/i18n/locales/en/preferences.json b/open-pdf-studio/js/i18n/locales/en/preferences.json index 2a18c5db8..1599bc5b7 100644 --- a/open-pdf-studio/js/i18n/locales/en/preferences.json +++ b/open-pdf-studio/js/i18n/locales/en/preferences.json @@ -142,7 +142,13 @@ "headOpenReversed": "Open Arrow (reversed)", "headClosedReversed": "Closed Arrow (reversed)", "headSlash": "Slash", - "polylineDefaults": "Polyline Defaults" + "polylineDefaults": "Polyline Defaults", + "smoothing": "Smoothing", + "pressure": "Pen pressure", + "pressureHint": "Pen pressure varies the stroke width (mouse strokes keep a constant width)", + "pressureMinWidth": "Minimum width (%)", + "penOnly": "Pen only (ignore touch)", + "penOnlyHint": "Touch input does not draw with the freehand tool; useful against palm contact while writing with a pen" }, "shapes": { "rectangleDefaults": "Rectangle Defaults", diff --git a/open-pdf-studio/js/i18n/locales/en/properties.json b/open-pdf-studio/js/i18n/locales/en/properties.json index f4fcbf811..4b622b0d5 100644 --- a/open-pdf-studio/js/i18n/locales/en/properties.json +++ b/open-pdf-studio/js/i18n/locales/en/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontal wide" } }, + "ink": { + "title": "Ink", + "pressure": "Pen pressure", + "pressureHint": "Pen pressure varies the stroke width (mouse strokes keep a constant width)", + "minWidth": "Minimum width (%)", + "minWidthHint": "Width at the lightest pen pressure, as a percentage of the line width", + "smoothing": "Smoothing", + "smoothingHint": "Strength of stroke smoothing (0 = none)" + }, "attachment": { "file": "File", "size": "Size", diff --git a/open-pdf-studio/js/i18n/locales/es/preferences.json b/open-pdf-studio/js/i18n/locales/es/preferences.json index 3cf6386c6..1954dc2b8 100644 --- a/open-pdf-studio/js/i18n/locales/es/preferences.json +++ b/open-pdf-studio/js/i18n/locales/es/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Flecha abierta (invertida)", "headClosedReversed": "Flecha cerrada (invertida)", "headSlash": "Barra", - "polylineDefaults": "Valores predeterminados de polílínea" + "polylineDefaults": "Valores predeterminados de polílínea", + "smoothing": "Suavizado", + "pressure": "Presión del lápiz", + "pressureHint": "La presión del lápiz varía el grosor del trazo (los trazos con ratón mantienen un grosor constante)", + "pressureMinWidth": "Ancho mínimo (%)", + "penOnly": "Solo lápiz (ignorar toque)", + "penOnlyHint": "El toque no dibuja con la herramienta a mano alzada; útil contra el contacto de la palma al escribir con lápiz" }, "shapes": { "rectangleDefaults": "Valores predeterminados de rectángulo", diff --git a/open-pdf-studio/js/i18n/locales/es/properties.json b/open-pdf-studio/js/i18n/locales/es/properties.json index 6a381bf4a..8053c1464 100644 --- a/open-pdf-studio/js/i18n/locales/es/properties.json +++ b/open-pdf-studio/js/i18n/locales/es/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontal ancho" } }, + "ink": { + "title": "Tinta", + "pressure": "Presión del lápiz", + "pressureHint": "La presión del lápiz varía el grosor del trazo (los trazos con ratón mantienen un grosor constante)", + "minWidth": "Ancho mínimo (%)", + "minWidthHint": "Ancho con la presión más suave, como porcentaje del grosor de línea", + "smoothing": "Suavizado", + "smoothingHint": "Intensidad del suavizado del trazo (0 = ninguno)" + }, "attachment": { "file": "Archivo", "size": "Tamaño", diff --git a/open-pdf-studio/js/i18n/locales/fa/preferences.json b/open-pdf-studio/js/i18n/locales/fa/preferences.json index bcc8fe9f1..a92379aef 100644 --- a/open-pdf-studio/js/i18n/locales/fa/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fa/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "فلش باز (معکوس)", "headClosedReversed": "فلش بسته (معکوس)", "headSlash": "خط مورب", - "polylineDefaults": "پیش‌فرض‌های چندخطی" + "polylineDefaults": "پیش‌فرض‌های چندخطی", + "smoothing": "هموارسازی", + "pressure": "فشار قلم", + "pressureHint": "فشار قلم پهنای خط را تغییر می‌دهد (خط‌های موس پهنای ثابت دارند)", + "pressureMinWidth": "کمترین پهنا (%)", + "penOnly": "فقط قلم (نادیده گرفتن لمس)", + "penOnlyHint": "لمس با ابزار دست‌آزاد نقاشی نمی‌کند؛ برای جلوگیری از تماس کف دست هنگام نوشتن با قلم مفید است" }, "shapes": { "rectangleDefaults": "پیش‌فرض‌های مستطیل", diff --git a/open-pdf-studio/js/i18n/locales/fa/properties.json b/open-pdf-studio/js/i18n/locales/fa/properties.json index 411b761f1..10ffcfc5c 100644 --- a/open-pdf-studio/js/i18n/locales/fa/properties.json +++ b/open-pdf-studio/js/i18n/locales/fa/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "افقی باز" } }, + "ink": { + "title": "جوهر", + "pressure": "فشار قلم", + "pressureHint": "فشار قلم پهنای خط را تغییر می‌دهد (خط‌های موس پهنای ثابت دارند)", + "minWidth": "کمترین پهنا (%)", + "minWidthHint": "پهنا در سبک‌ترین فشار قلم، به‌صورت درصدی از ضخامت خط", + "smoothing": "هموارسازی", + "smoothingHint": "شدت هموارسازی خط (۰ = بدون)" + }, "attachment": { "file": "فایل", "size": "اندازه", diff --git a/open-pdf-studio/js/i18n/locales/fi/preferences.json b/open-pdf-studio/js/i18n/locales/fi/preferences.json index 03db3c107..3ed101354 100644 --- a/open-pdf-studio/js/i18n/locales/fi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fi/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Avoin nuoli (käänteinen)", "headClosedReversed": "Suljettu nuoli (käänteinen)", "headSlash": "Vinoviiva", - "polylineDefaults": "Murtoviivan oletukset" + "polylineDefaults": "Murtoviivan oletukset", + "smoothing": "Tasoitus", + "pressure": "Kynän paine", + "pressureHint": "Kynän paine muuttaa viivan leveyttä (hiirellä piirretty viiva pysyy saman levyisenä)", + "pressureMinWidth": "Vähimmäisleveys (%)", + "penOnly": "Vain kynä (ohita kosketus)", + "penOnlyHint": "Kosketus ei piirrä vapaalla kädellä; hyödyllinen kämmenen kosketusta vastaan kynällä kirjoitettaessa" }, "shapes": { "rectangleDefaults": "Suorakulmion oletukset", diff --git a/open-pdf-studio/js/i18n/locales/fi/properties.json b/open-pdf-studio/js/i18n/locales/fi/properties.json index 9fbdd14c5..1d5ebee15 100644 --- a/open-pdf-studio/js/i18n/locales/fi/properties.json +++ b/open-pdf-studio/js/i18n/locales/fi/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vaakaviivoitus harva" } }, + "ink": { + "title": "Muste", + "pressure": "Kynän paine", + "pressureHint": "Kynän paine muuttaa viivan leveyttä (hiirellä piirretty viiva pysyy saman levyisenä)", + "minWidth": "Vähimmäisleveys (%)", + "minWidthHint": "Leveys kevyimmällä kynän paineella prosentteina viivan leveydestä", + "smoothing": "Tasoitus", + "smoothingHint": "Viivan tasoituksen voimakkuus (0 = ei tasoitusta)" + }, "attachment": { "file": "Tiedosto", "size": "Koko", diff --git a/open-pdf-studio/js/i18n/locales/fr/preferences.json b/open-pdf-studio/js/i18n/locales/fr/preferences.json index 5e03be1ac..fecc6b6d8 100644 --- a/open-pdf-studio/js/i18n/locales/fr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/fr/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Flèche ouverte (inversée)", "headClosedReversed": "Flèche fermée (inversée)", "headSlash": "Barre oblique", - "polylineDefaults": "Valeurs par défaut de la polyligne" + "polylineDefaults": "Valeurs par défaut de la polyligne", + "smoothing": "Lissage", + "pressure": "Pression du stylet", + "pressureHint": "La pression du stylet fait varier l'épaisseur du trait (un trait à la souris garde une épaisseur constante)", + "pressureMinWidth": "Largeur minimale (%)", + "penOnly": "Stylet uniquement (ignorer le toucher)", + "penOnlyHint": "Le toucher ne dessine pas avec l'outil main levée ; utile contre le contact de la paume en écrivant au stylet" }, "shapes": { "rectangleDefaults": "Valeurs par défaut du rectangle", diff --git a/open-pdf-studio/js/i18n/locales/fr/properties.json b/open-pdf-studio/js/i18n/locales/fr/properties.json index 2f123bc3d..6ffcab353 100644 --- a/open-pdf-studio/js/i18n/locales/fr/properties.json +++ b/open-pdf-studio/js/i18n/locales/fr/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontal espacé" } }, + "ink": { + "title": "Encre", + "pressure": "Pression du stylet", + "pressureHint": "La pression du stylet fait varier l'épaisseur du trait (un trait à la souris garde une épaisseur constante)", + "minWidth": "Largeur minimale (%)", + "minWidthHint": "Largeur à la pression la plus légère, en pourcentage de l'épaisseur du trait", + "smoothing": "Lissage", + "smoothingHint": "Intensité du lissage du trait (0 = aucun)" + }, "attachment": { "file": "Fichier", "size": "Taille", diff --git a/open-pdf-studio/js/i18n/locales/he/preferences.json b/open-pdf-studio/js/i18n/locales/he/preferences.json index 70f1f8e1d..bfc71d982 100644 --- a/open-pdf-studio/js/i18n/locales/he/preferences.json +++ b/open-pdf-studio/js/i18n/locales/he/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "חץ פתוח (הפוך)", "headClosedReversed": "חץ סגור (הפוך)", "headSlash": "קו נטוי", - "polylineDefaults": "ברירות מחדל לקו מרובה" + "polylineDefaults": "ברירות מחדל לקו מרובה", + "smoothing": "החלקה", + "pressure": "לחץ עט", + "pressureHint": "לחץ העט משנה את עובי הקו (קווים בעכבר שומרים על עובי קבוע)", + "pressureMinWidth": "רוחב מינימלי (%)", + "penOnly": "עט בלבד (התעלם ממגע)", + "penOnlyHint": "מגע אינו מצייר בכלי היד החופשית; שימושי נגד מגע כף היד בזמן כתיבה בעט" }, "shapes": { "rectangleDefaults": "ברירות מחדל למלבן", diff --git a/open-pdf-studio/js/i18n/locales/he/properties.json b/open-pdf-studio/js/i18n/locales/he/properties.json index 5337c905b..19c95793b 100644 --- a/open-pdf-studio/js/i18n/locales/he/properties.json +++ b/open-pdf-studio/js/i18n/locales/he/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "אופקי מרווח" } }, + "ink": { + "title": "דיו", + "pressure": "לחץ עט", + "pressureHint": "לחץ העט משנה את עובי הקו (קווים בעכבר שומרים על עובי קבוע)", + "minWidth": "רוחב מינימלי (%)", + "minWidthHint": "הרוחב בלחץ הקל ביותר, באחוזים מעובי הקו", + "smoothing": "החלקה", + "smoothingHint": "עוצמת החלקת הקו (0 = ללא)" + }, "attachment": { "file": "קובץ", "size": "גודל", diff --git a/open-pdf-studio/js/i18n/locales/hi/preferences.json b/open-pdf-studio/js/i18n/locales/hi/preferences.json index a5274ba21..5e0041c34 100644 --- a/open-pdf-studio/js/i18n/locales/hi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hi/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "खुला तीर (उल्टा)", "headClosedReversed": "बंद तीर (उल्टा)", "headSlash": "स्लैश", - "polylineDefaults": "पॉलीलाइन डिफ़ॉल्ट" + "polylineDefaults": "पॉलीलाइन डिफ़ॉल्ट", + "smoothing": "स्मूदिंग", + "pressure": "पेन दबाव", + "pressureHint": "पेन का दबाव रेखा की चौड़ाई बदलता है (माउस की रेखाएँ एक समान चौड़ाई रखती हैं)", + "pressureMinWidth": "न्यूनतम चौड़ाई (%)", + "penOnly": "केवल पेन (स्पर्श अनदेखा करें)", + "penOnlyHint": "स्पर्श से फ्रीहैंड टूल से चित्र नहीं बनता; पेन से लिखते समय हथेली के स्पर्श से बचाव के लिए उपयोगी" }, "shapes": { "rectangleDefaults": "आयत डिफ़ॉल्ट", diff --git a/open-pdf-studio/js/i18n/locales/hi/properties.json b/open-pdf-studio/js/i18n/locales/hi/properties.json index 42e7640d9..97882f1b7 100644 --- a/open-pdf-studio/js/i18n/locales/hi/properties.json +++ b/open-pdf-studio/js/i18n/locales/hi/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "क्षैतिज विरल" } }, + "ink": { + "title": "स्याही", + "pressure": "पेन दबाव", + "pressureHint": "पेन का दबाव रेखा की चौड़ाई बदलता है (माउस की रेखाएँ एक समान चौड़ाई रखती हैं)", + "minWidth": "न्यूनतम चौड़ाई (%)", + "minWidthHint": "सबसे हल्के पेन दबाव पर चौड़ाई, रेखा की मोटाई के प्रतिशत के रूप में", + "smoothing": "स्मूदिंग", + "smoothingHint": "रेखा स्मूदिंग की तीव्रता (0 = कोई नहीं)" + }, "attachment": { "file": "फ़ाइल", "size": "आकार", diff --git a/open-pdf-studio/js/i18n/locales/hr/preferences.json b/open-pdf-studio/js/i18n/locales/hr/preferences.json index 5d6e1a906..0d8b76f6c 100644 --- a/open-pdf-studio/js/i18n/locales/hr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hr/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Otvorena strelica (obrnuta)", "headClosedReversed": "Zatvorena strelica (obrnuta)", "headSlash": "Kosa crta", - "polylineDefaults": "Zadane postavke polilinije" + "polylineDefaults": "Zadane postavke polilinije", + "smoothing": "Zaglađivanje", + "pressure": "Pritisak olovke", + "pressureHint": "Pritisak olovke mijenja debljinu poteza (potezi mišem zadržavaju stalnu debljinu)", + "pressureMinWidth": "Najmanja širina (%)", + "penOnly": "Samo olovka (zanemari dodir)", + "penOnlyHint": "Dodir ne crta alatom slobodne ruke; korisno protiv dodira dlana pri pisanju olovkom" }, "shapes": { "rectangleDefaults": "Zadane postavke pravokutnika", diff --git a/open-pdf-studio/js/i18n/locales/hr/properties.json b/open-pdf-studio/js/i18n/locales/hr/properties.json index 93e512d4f..6d930d56a 100644 --- a/open-pdf-studio/js/i18n/locales/hr/properties.json +++ b/open-pdf-studio/js/i18n/locales/hr/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vodoravna šrafura rijetka" } }, + "ink": { + "title": "Tinta", + "pressure": "Pritisak olovke", + "pressureHint": "Pritisak olovke mijenja debljinu poteza (potezi mišem zadržavaju stalnu debljinu)", + "minWidth": "Najmanja širina (%)", + "minWidthHint": "Širina pri najlakšem pritisku, kao postotak debljine linije", + "smoothing": "Zaglađivanje", + "smoothingHint": "Jačina zaglađivanja poteza (0 = bez)" + }, "attachment": { "file": "Datoteka", "size": "Veličina", diff --git a/open-pdf-studio/js/i18n/locales/hu/preferences.json b/open-pdf-studio/js/i18n/locales/hu/preferences.json index edf0029a1..548b7b9d5 100644 --- a/open-pdf-studio/js/i18n/locales/hu/preferences.json +++ b/open-pdf-studio/js/i18n/locales/hu/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Nyitott nyíl (fordított)", "headClosedReversed": "Zárt nyíl (fordított)", "headSlash": "Perjel", - "polylineDefaults": "Töröttvonal alapértelmezések" + "polylineDefaults": "Töröttvonal alapértelmezések", + "smoothing": "Simítás", + "pressure": "Tollnyomás", + "pressureHint": "A tollnyomás változtatja a vonal szélességét (az egérrel húzott vonal szélessége állandó)", + "pressureMinWidth": "Minimális szélesség (%)", + "penOnly": "Csak toll (érintés figyelmen kívül hagyása)", + "penOnlyHint": "Az érintés nem rajzol a szabadkézi eszközzel; hasznos a tenyér érintése ellen tollal való írás közben" }, "shapes": { "rectangleDefaults": "Téglalap alapértelmezések", diff --git a/open-pdf-studio/js/i18n/locales/hu/properties.json b/open-pdf-studio/js/i18n/locales/hu/properties.json index 305c913c3..307dc53b2 100644 --- a/open-pdf-studio/js/i18n/locales/hu/properties.json +++ b/open-pdf-studio/js/i18n/locales/hu/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vízszintes sraffozás ritka" } }, + "ink": { + "title": "Tinta", + "pressure": "Tollnyomás", + "pressureHint": "A tollnyomás változtatja a vonal szélességét (az egérrel húzott vonal szélessége állandó)", + "minWidth": "Minimális szélesség (%)", + "minWidthHint": "Szélesség a legkisebb tollnyomásnál, a vonalvastagság százalékában", + "smoothing": "Simítás", + "smoothingHint": "A vonalsimítás erőssége (0 = nincs)" + }, "attachment": { "file": "Fájl", "size": "Méret", diff --git a/open-pdf-studio/js/i18n/locales/id/preferences.json b/open-pdf-studio/js/i18n/locales/id/preferences.json index 875d052b8..bb1a96f02 100644 --- a/open-pdf-studio/js/i18n/locales/id/preferences.json +++ b/open-pdf-studio/js/i18n/locales/id/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Panah Terbuka (terbalik)", "headClosedReversed": "Panah Tertutup (terbalik)", "headSlash": "Garis Miring", - "polylineDefaults": "Bawaan Polyline" + "polylineDefaults": "Bawaan Polyline", + "smoothing": "Penghalusan", + "pressure": "Tekanan pena", + "pressureHint": "Tekanan pena mengubah lebar goresan (goresan mouse tetap selebar yang sama)", + "pressureMinWidth": "Lebar minimum (%)", + "penOnly": "Hanya pena (abaikan sentuhan)", + "penOnlyHint": "Sentuhan tidak menggambar dengan alat tangan bebas; berguna mencegah sentuhan telapak tangan saat menulis dengan pena" }, "shapes": { "rectangleDefaults": "Bawaan Persegi Panjang", diff --git a/open-pdf-studio/js/i18n/locales/id/properties.json b/open-pdf-studio/js/i18n/locales/id/properties.json index 1a78f28f7..fedaace89 100644 --- a/open-pdf-studio/js/i18n/locales/id/properties.json +++ b/open-pdf-studio/js/i18n/locales/id/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Mendatar renggang" } }, + "ink": { + "title": "Tinta", + "pressure": "Tekanan pena", + "pressureHint": "Tekanan pena mengubah lebar goresan (goresan mouse tetap selebar yang sama)", + "minWidth": "Lebar minimum (%)", + "minWidthHint": "Lebar pada tekanan pena paling ringan, dalam persen dari ketebalan garis", + "smoothing": "Penghalusan", + "smoothingHint": "Kekuatan penghalusan goresan (0 = tidak ada)" + }, "attachment": { "file": "File", "size": "Ukuran", diff --git a/open-pdf-studio/js/i18n/locales/it/preferences.json b/open-pdf-studio/js/i18n/locales/it/preferences.json index 93e644055..adc1003e4 100644 --- a/open-pdf-studio/js/i18n/locales/it/preferences.json +++ b/open-pdf-studio/js/i18n/locales/it/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Freccia aperta (invertita)", "headClosedReversed": "Freccia chiusa (invertita)", "headSlash": "Barra", - "polylineDefaults": "Predefiniti polilinea" + "polylineDefaults": "Predefiniti polilinea", + "smoothing": "Smussatura", + "pressure": "Pressione della penna", + "pressureHint": "La pressione della penna varia lo spessore del tratto (i tratti col mouse mantengono uno spessore costante)", + "pressureMinWidth": "Larghezza minima (%)", + "penOnly": "Solo penna (ignora il tocco)", + "penOnlyHint": "Il tocco non disegna con lo strumento a mano libera; utile contro il contatto del palmo mentre si scrive con la penna" }, "shapes": { "rectangleDefaults": "Predefiniti rettangolo", diff --git a/open-pdf-studio/js/i18n/locales/it/properties.json b/open-pdf-studio/js/i18n/locales/it/properties.json index 4f83cc169..331d245fb 100644 --- a/open-pdf-studio/js/i18n/locales/it/properties.json +++ b/open-pdf-studio/js/i18n/locales/it/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Orizzontale rado" } }, + "ink": { + "title": "Inchiostro", + "pressure": "Pressione della penna", + "pressureHint": "La pressione della penna varia lo spessore del tratto (i tratti col mouse mantengono uno spessore costante)", + "minWidth": "Larghezza minima (%)", + "minWidthHint": "Larghezza con la pressione più leggera, in percentuale dello spessore della linea", + "smoothing": "Smussatura", + "smoothingHint": "Intensità della smussatura del tratto (0 = nessuna)" + }, "attachment": { "file": "File", "size": "Dimensione", diff --git a/open-pdf-studio/js/i18n/locales/ja/preferences.json b/open-pdf-studio/js/i18n/locales/ja/preferences.json index 8e6183593..c5a52e9e0 100644 --- a/open-pdf-studio/js/i18n/locales/ja/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ja/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "開いた矢印(反転)", "headClosedReversed": "閉じた矢印(反転)", "headSlash": "スラッシュ", - "polylineDefaults": "ポリラインの既定値" + "polylineDefaults": "ポリラインの既定値", + "smoothing": "スムージング", + "pressure": "筆圧", + "pressureHint": "筆圧で線の太さが変わります(マウスの線は一定の太さです)", + "pressureMinWidth": "最小幅 (%)", + "penOnly": "ペンのみ(タッチを無視)", + "penOnlyHint": "タッチではフリーハンドツールで描画しません。ペンで書くときの手のひらの接触を防ぐのに役立ちます" }, "shapes": { "rectangleDefaults": "四角形の既定値", diff --git a/open-pdf-studio/js/i18n/locales/ja/properties.json b/open-pdf-studio/js/i18n/locales/ja/properties.json index 6e6f30ccb..a62f21b31 100644 --- a/open-pdf-studio/js/i18n/locales/ja/properties.json +++ b/open-pdf-studio/js/i18n/locales/ja/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "横線 疎" } }, + "ink": { + "title": "インク", + "pressure": "筆圧", + "pressureHint": "筆圧で線の太さが変わります(マウスの線は一定の太さです)", + "minWidth": "最小幅 (%)", + "minWidthHint": "最も軽い筆圧での幅(線幅に対する割合)", + "smoothing": "スムージング", + "smoothingHint": "線のスムージングの強さ(0 = なし)" + }, "attachment": { "file": "ファイル", "size": "サイズ", diff --git a/open-pdf-studio/js/i18n/locales/ko/preferences.json b/open-pdf-studio/js/i18n/locales/ko/preferences.json index b68a7a771..b5846ccd9 100644 --- a/open-pdf-studio/js/i18n/locales/ko/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ko/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "열린 화살표(반대 방향)", "headClosedReversed": "닫힌 화살표(반대 방향)", "headSlash": "슬래시", - "polylineDefaults": "폴리라인 기본값" + "polylineDefaults": "폴리라인 기본값", + "smoothing": "다듬기", + "pressure": "펜 압력", + "pressureHint": "펜 압력에 따라 선 굵기가 달라집니다 (마우스로 그린 선은 굵기가 일정합니다)", + "pressureMinWidth": "최소 너비 (%)", + "penOnly": "펜만 사용 (터치 무시)", + "penOnlyHint": "터치로는 자유 그리기 도구로 그리지 않습니다. 펜으로 쓸 때 손바닥이 닿는 것을 막는 데 유용합니다" }, "shapes": { "rectangleDefaults": "사각형 기본값", diff --git a/open-pdf-studio/js/i18n/locales/ko/properties.json b/open-pdf-studio/js/i18n/locales/ko/properties.json index c6b063bf0..77ea4d51e 100644 --- a/open-pdf-studio/js/i18n/locales/ko/properties.json +++ b/open-pdf-studio/js/i18n/locales/ko/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "가로선 넓음" } }, + "ink": { + "title": "잉크", + "pressure": "펜 압력", + "pressureHint": "펜 압력에 따라 선 굵기가 달라집니다 (마우스로 그린 선은 굵기가 일정합니다)", + "minWidth": "최소 너비 (%)", + "minWidthHint": "가장 약한 펜 압력에서의 너비 (선 굵기에 대한 백분율)", + "smoothing": "다듬기", + "smoothingHint": "선 다듬기 강도 (0 = 없음)" + }, "attachment": { "file": "파일", "size": "크기", diff --git a/open-pdf-studio/js/i18n/locales/lo/preferences.json b/open-pdf-studio/js/i18n/locales/lo/preferences.json index 80a7bc2de..4a1c31f87 100644 --- a/open-pdf-studio/js/i18n/locales/lo/preferences.json +++ b/open-pdf-studio/js/i18n/locales/lo/preferences.json @@ -140,7 +140,13 @@ "headOpenReversed": "ລູກສອນໂປ່ງ (ກັບດ້ານ)", "headClosedReversed": "ລູກສອນຕັນ (ກັບດ້ານ)", "headSlash": "ເສັ້ນຂີດຕັດ", - "polylineDefaults": "ຄ່າເລີ່ມຕົ້ນເສັ້ນຕໍ່ເນື່ອງ" + "polylineDefaults": "ຄ່າເລີ່ມຕົ້ນເສັ້ນຕໍ່ເນື່ອງ", + "smoothing": "ການເຮັດໃຫ້ລຽບ", + "pressure": "ແຮງກົດປາກກາ", + "pressureHint": "ແຮງກົດປາກກາປ່ຽນຄວາມກວ້າງຂອງເສັ້ນ (ເສັ້ນຈາກເມົ້າມີຄວາມກວ້າງຄົງທີ່)", + "pressureMinWidth": "ຄວາມກວ້າງຕໍ່າສຸດ (%)", + "penOnly": "ປາກກາເທົ່ານັ້ນ (ບໍ່ສົນໃຈການສຳຜັດ)", + "penOnlyHint": "ການສຳຜັດຈະບໍ່ແຕ້ມດ້ວຍເຄື່ອງມືແຕ້ມອິດສະຫຼະ ຊ່ວຍປ້ອງກັນຝາມືແຕະໜ້າຈໍຂະນະຂຽນດ້ວຍປາກກາ" }, "shapes": { "rectangleDefaults": "ຄ່າເລີ່ມຕົ້ນຮູບສີ່ແຈ", diff --git a/open-pdf-studio/js/i18n/locales/lo/properties.json b/open-pdf-studio/js/i18n/locales/lo/properties.json index c7a24b48b..907607d64 100644 --- a/open-pdf-studio/js/i18n/locales/lo/properties.json +++ b/open-pdf-studio/js/i18n/locales/lo/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "ເສັ້ນແນວນອນຫ່າງ" } }, + "ink": { + "title": "ໝຶກ", + "pressure": "ແຮງກົດປາກກາ", + "pressureHint": "ແຮງກົດປາກກາປ່ຽນຄວາມກວ້າງຂອງເສັ້ນ (ເສັ້ນຈາກເມົ້າມີຄວາມກວ້າງຄົງທີ່)", + "minWidth": "ຄວາມກວ້າງຕໍ່າສຸດ (%)", + "minWidthHint": "ຄວາມກວ້າງທີ່ແຮງກົດເບົາທີ່ສຸດ ເປັນເປີເຊັນຂອງຄວາມໜາເສັ້ນ", + "smoothing": "ການເຮັດໃຫ້ລຽບ", + "smoothingHint": "ລະດັບການເຮັດໃຫ້ເສັ້ນລຽບ (0 = ບໍ່ມີ)" + }, "attachment": { "file": "ໄຟລ໌", "size": "ຂະໜາດ", diff --git a/open-pdf-studio/js/i18n/locales/ms/preferences.json b/open-pdf-studio/js/i18n/locales/ms/preferences.json index 8299940c4..ee8d38634 100644 --- a/open-pdf-studio/js/i18n/locales/ms/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ms/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Anak Panah Terbuka (terbalik)", "headClosedReversed": "Anak Panah Tertutup (terbalik)", "headSlash": "Palang", - "polylineDefaults": "Lalai Polyline" + "polylineDefaults": "Lalai Polyline", + "smoothing": "Pelicinan", + "pressure": "Tekanan pen", + "pressureHint": "Tekanan pen mengubah lebar lakaran (lakaran tetikus kekal dengan lebar yang sama)", + "pressureMinWidth": "Lebar minimum (%)", + "penOnly": "Pen sahaja (abaikan sentuhan)", + "penOnlyHint": "Sentuhan tidak melukis dengan alat bebas; berguna mengelakkan sentuhan tapak tangan semasa menulis dengan pen" }, "shapes": { "rectangleDefaults": "Lalai Segi Empat Tepat", diff --git a/open-pdf-studio/js/i18n/locales/ms/properties.json b/open-pdf-studio/js/i18n/locales/ms/properties.json index 22ee25542..714f437cc 100644 --- a/open-pdf-studio/js/i18n/locales/ms/properties.json +++ b/open-pdf-studio/js/i18n/locales/ms/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Mendatar jarang" } }, + "ink": { + "title": "Dakwat", + "pressure": "Tekanan pen", + "pressureHint": "Tekanan pen mengubah lebar lakaran (lakaran tetikus kekal dengan lebar yang sama)", + "minWidth": "Lebar minimum (%)", + "minWidthHint": "Lebar pada tekanan pen paling ringan, sebagai peratus ketebalan garisan", + "smoothing": "Pelicinan", + "smoothingHint": "Kekuatan pelicinan lakaran (0 = tiada)" + }, "attachment": { "file": "Fail", "size": "Saiz", diff --git a/open-pdf-studio/js/i18n/locales/nb/preferences.json b/open-pdf-studio/js/i18n/locales/nb/preferences.json index d02f9e7c5..61a623ec4 100644 --- a/open-pdf-studio/js/i18n/locales/nb/preferences.json +++ b/open-pdf-studio/js/i18n/locales/nb/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Åpen pil (omvendt)", "headClosedReversed": "Lukket pil (omvendt)", "headSlash": "Skråstrek", - "polylineDefaults": "Polylinjestandarder" + "polylineDefaults": "Polylinjestandarder", + "smoothing": "Utjevning", + "pressure": "Pennetrykk", + "pressureHint": "Pennetrykket endrer strekbredden (musestreker beholder fast bredde)", + "pressureMinWidth": "Minste bredde (%)", + "penOnly": "Bare penn (ignorer berøring)", + "penOnlyHint": "Berøring tegner ikke med frihåndsverktøyet; nyttig mot håndflaten når du skriver med penn" }, "shapes": { "rectangleDefaults": "Rektangelstandarder", diff --git a/open-pdf-studio/js/i18n/locales/nb/properties.json b/open-pdf-studio/js/i18n/locales/nb/properties.json index 49c726dd2..967ab7b74 100644 --- a/open-pdf-studio/js/i18n/locales/nb/properties.json +++ b/open-pdf-studio/js/i18n/locales/nb/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vannrett åpen" } }, + "ink": { + "title": "Blekk", + "pressure": "Pennetrykk", + "pressureHint": "Pennetrykket endrer strekbredden (musestreker beholder fast bredde)", + "minWidth": "Minste bredde (%)", + "minWidthHint": "Bredde ved letteste pennetrykk, i prosent av linjebredden", + "smoothing": "Utjevning", + "smoothingHint": "Styrke på utjevning av streken (0 = ingen)" + }, "attachment": { "file": "Fil", "size": "Størrelse", diff --git a/open-pdf-studio/js/i18n/locales/nl/preferences.json b/open-pdf-studio/js/i18n/locales/nl/preferences.json index c6f468c75..21fa83839 100644 --- a/open-pdf-studio/js/i18n/locales/nl/preferences.json +++ b/open-pdf-studio/js/i18n/locales/nl/preferences.json @@ -108,7 +108,13 @@ "end": "Eind", "headButt": "Plat", "headOpenReversed": "Open pijl (omgekeerd)", - "headClosedReversed": "Gesloten pijl (omgekeerd)" + "headClosedReversed": "Gesloten pijl (omgekeerd)", + "smoothing": "Gladheid", + "pressure": "Pendruk", + "pressureHint": "Pendruk varieert de lijndikte (een muisstreek houdt een vaste dikte)", + "pressureMinWidth": "Minimale breedte (%)", + "penOnly": "Alleen pen (aanraking negeren)", + "penOnlyHint": "Aanraking tekent niet met de vrije hand; handig tegen de handpalm op het scherm tijdens het schrijven met een pen" }, "shapes": { "rectangleDefaults": "Rechthoek standaardwaarden", diff --git a/open-pdf-studio/js/i18n/locales/nl/properties.json b/open-pdf-studio/js/i18n/locales/nl/properties.json index ea4e7ac7b..b8182968c 100644 --- a/open-pdf-studio/js/i18n/locales/nl/properties.json +++ b/open-pdf-studio/js/i18n/locales/nl/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontaal breed" } }, + "ink": { + "title": "Inkt", + "pressure": "Pendruk", + "pressureHint": "Pendruk varieert de lijndikte (een muisstreek houdt een vaste dikte)", + "minWidth": "Minimale breedte (%)", + "minWidthHint": "Breedte bij de lichtste pendruk, als percentage van de lijndikte", + "smoothing": "Gladheid", + "smoothingHint": "Sterkte van het gladmaken van de streek (0 = uit)" + }, "attachment": { "file": "Bestand", "size": "Grootte", diff --git a/open-pdf-studio/js/i18n/locales/pl/preferences.json b/open-pdf-studio/js/i18n/locales/pl/preferences.json index f2621262b..56c721f25 100644 --- a/open-pdf-studio/js/i18n/locales/pl/preferences.json +++ b/open-pdf-studio/js/i18n/locales/pl/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Otwarta strzałka (odwrócona)", "headClosedReversed": "Zamknięta strzałka (odwrócona)", "headSlash": "Ukośnik", - "polylineDefaults": "Ustawienia domyślne polilinii" + "polylineDefaults": "Ustawienia domyślne polilinii", + "smoothing": "Wygładzanie", + "pressure": "Nacisk pióra", + "pressureHint": "Nacisk pióra zmienia grubość linii (linie rysowane myszą mają stałą grubość)", + "pressureMinWidth": "Minimalna szerokość (%)", + "penOnly": "Tylko pióro (ignoruj dotyk)", + "penOnlyHint": "Dotyk nie rysuje narzędziem odręcznym; przydatne przeciw dotykowi dłoni podczas pisania piórem" }, "shapes": { "rectangleDefaults": "Ustawienia domyślne prostokąta", diff --git a/open-pdf-studio/js/i18n/locales/pl/properties.json b/open-pdf-studio/js/i18n/locales/pl/properties.json index fbf881b2d..797dba0aa 100644 --- a/open-pdf-studio/js/i18n/locales/pl/properties.json +++ b/open-pdf-studio/js/i18n/locales/pl/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Kreskowanie poziome rzadkie" } }, + "ink": { + "title": "Atrament", + "pressure": "Nacisk pióra", + "pressureHint": "Nacisk pióra zmienia grubość linii (linie rysowane myszą mają stałą grubość)", + "minWidth": "Minimalna szerokość (%)", + "minWidthHint": "Szerokość przy najlżejszym nacisku, jako procent grubości linii", + "smoothing": "Wygładzanie", + "smoothingHint": "Siła wygładzania linii (0 = brak)" + }, "attachment": { "file": "Plik", "size": "Rozmiar", diff --git a/open-pdf-studio/js/i18n/locales/pt/preferences.json b/open-pdf-studio/js/i18n/locales/pt/preferences.json index a834024bf..f22e595e5 100644 --- a/open-pdf-studio/js/i18n/locales/pt/preferences.json +++ b/open-pdf-studio/js/i18n/locales/pt/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Seta aberta (invertida)", "headClosedReversed": "Seta fechada (invertida)", "headSlash": "Barra", - "polylineDefaults": "Padrões de polilinha" + "polylineDefaults": "Padrões de polilinha", + "smoothing": "Suavização", + "pressure": "Pressão da caneta", + "pressureHint": "A pressão da caneta varia a espessura do traço (traços com o rato mantêm uma espessura constante)", + "pressureMinWidth": "Largura mínima (%)", + "penOnly": "Apenas caneta (ignorar toque)", + "penOnlyHint": "O toque não desenha com a ferramenta à mão livre; útil contra o contacto da palma ao escrever com a caneta" }, "shapes": { "rectangleDefaults": "Padrões do retângulo", diff --git a/open-pdf-studio/js/i18n/locales/pt/properties.json b/open-pdf-studio/js/i18n/locales/pt/properties.json index 96e81a3db..054c49a69 100644 --- a/open-pdf-studio/js/i18n/locales/pt/properties.json +++ b/open-pdf-studio/js/i18n/locales/pt/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Horizontal largo" } }, + "ink": { + "title": "Tinta", + "pressure": "Pressão da caneta", + "pressureHint": "A pressão da caneta varia a espessura do traço (traços com o rato mantêm uma espessura constante)", + "minWidth": "Largura mínima (%)", + "minWidthHint": "Largura com a pressão mais leve, em percentagem da espessura da linha", + "smoothing": "Suavização", + "smoothingHint": "Intensidade da suavização do traço (0 = nenhuma)" + }, "attachment": { "file": "Arquivo", "size": "Tamanho", diff --git a/open-pdf-studio/js/i18n/locales/ro/preferences.json b/open-pdf-studio/js/i18n/locales/ro/preferences.json index ee1e50b8f..0c9c96649 100644 --- a/open-pdf-studio/js/i18n/locales/ro/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ro/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Săgeată deschisă (inversată)", "headClosedReversed": "Săgeată închisă (inversată)", "headSlash": "Linie oblica", - "polylineDefaults": "Valori implicite polilinie" + "polylineDefaults": "Valori implicite polilinie", + "smoothing": "Netezire", + "pressure": "Presiunea stiloului", + "pressureHint": "Presiunea stiloului variază grosimea liniei (liniile trase cu mouse-ul păstrează o grosime constantă)", + "pressureMinWidth": "Lățime minimă (%)", + "penOnly": "Doar stilou (ignoră atingerea)", + "penOnlyHint": "Atingerea nu desenează cu instrumentul de mână liberă; util împotriva contactului palmei când scrieți cu stiloul" }, "shapes": { "rectangleDefaults": "Valori implicite dreptunghi", diff --git a/open-pdf-studio/js/i18n/locales/ro/properties.json b/open-pdf-studio/js/i18n/locales/ro/properties.json index d07deab24..413fd26c1 100644 --- a/open-pdf-studio/js/i18n/locales/ro/properties.json +++ b/open-pdf-studio/js/i18n/locales/ro/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Orizontal larg" } }, + "ink": { + "title": "Cerneală", + "pressure": "Presiunea stiloului", + "pressureHint": "Presiunea stiloului variază grosimea liniei (liniile trase cu mouse-ul păstrează o grosime constantă)", + "minWidth": "Lățime minimă (%)", + "minWidthHint": "Lățimea la cea mai ușoară presiune, ca procent din grosimea liniei", + "smoothing": "Netezire", + "smoothingHint": "Intensitatea netezirii liniei (0 = deloc)" + }, "attachment": { "file": "Fișier", "size": "Dimensiune", diff --git a/open-pdf-studio/js/i18n/locales/ru/preferences.json b/open-pdf-studio/js/i18n/locales/ru/preferences.json index 2b9c0a3f8..c0a708508 100644 --- a/open-pdf-studio/js/i18n/locales/ru/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ru/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Открытая стрелка (обратная)", "headClosedReversed": "Закрытая стрелка (обратная)", "headSlash": "Косая черта", - "polylineDefaults": "Настройки полилинии по умолчанию" + "polylineDefaults": "Настройки полилинии по умолчанию", + "smoothing": "Сглаживание", + "pressure": "Нажим пера", + "pressureHint": "Нажим пера меняет толщину штриха (штрихи мышью сохраняют постоянную толщину)", + "pressureMinWidth": "Минимальная ширина (%)", + "penOnly": "Только перо (игнорировать касания)", + "penOnlyHint": "Касание не рисует инструментом «от руки»; помогает против касания ладонью при письме пером" }, "shapes": { "rectangleDefaults": "Настройки прямоугольника по умолчанию", diff --git a/open-pdf-studio/js/i18n/locales/ru/properties.json b/open-pdf-studio/js/i18n/locales/ru/properties.json index 23e742587..5ef97856d 100644 --- a/open-pdf-studio/js/i18n/locales/ru/properties.json +++ b/open-pdf-studio/js/i18n/locales/ru/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Горизонтальная штриховка редкая" } }, + "ink": { + "title": "Чернила", + "pressure": "Нажим пера", + "pressureHint": "Нажим пера меняет толщину штриха (штрихи мышью сохраняют постоянную толщину)", + "minWidth": "Минимальная ширина (%)", + "minWidthHint": "Ширина при самом лёгком нажиме, в процентах от толщины линии", + "smoothing": "Сглаживание", + "smoothingHint": "Сила сглаживания штриха (0 = нет)" + }, "attachment": { "file": "Файл", "size": "Размер", diff --git a/open-pdf-studio/js/i18n/locales/sk/preferences.json b/open-pdf-studio/js/i18n/locales/sk/preferences.json index 59d098b7e..21027afaf 100644 --- a/open-pdf-studio/js/i18n/locales/sk/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sk/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Otvorená šípka (obrátená)", "headClosedReversed": "Uzavretá šípka (obrátená)", "headSlash": "Lomka", - "polylineDefaults": "Predvolené nastavenia lomenej čiary" + "polylineDefaults": "Predvolené nastavenia lomenej čiary", + "smoothing": "Vyhladenie", + "pressure": "Prítlak pera", + "pressureHint": "Prítlak pera mení šírku ťahu (ťahy myšou majú stálu šírku)", + "pressureMinWidth": "Minimálna šírka (%)", + "penOnly": "Iba pero (ignorovať dotyk)", + "penOnlyHint": "Dotyk nekreslí nástrojom voľnej ruky; užitočné proti dotyku dlane pri písaní perom" }, "shapes": { "rectangleDefaults": "Predvolené nastavenia obdĺžnika", diff --git a/open-pdf-studio/js/i18n/locales/sk/properties.json b/open-pdf-studio/js/i18n/locales/sk/properties.json index 0ca43a17d..b83171c9a 100644 --- a/open-pdf-studio/js/i18n/locales/sk/properties.json +++ b/open-pdf-studio/js/i18n/locales/sk/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vodorovná šrafa riedka" } }, + "ink": { + "title": "Atrament", + "pressure": "Prítlak pera", + "pressureHint": "Prítlak pera mení šírku ťahu (ťahy myšou majú stálu šírku)", + "minWidth": "Minimálna šírka (%)", + "minWidthHint": "Šírka pri najľahšom prítlaku, v percentách hrúbky čiary", + "smoothing": "Vyhladenie", + "smoothingHint": "Sila vyhladenia ťahu (0 = žiadne)" + }, "attachment": { "file": "Súbor", "size": "Veľkosť", diff --git a/open-pdf-studio/js/i18n/locales/sr/preferences.json b/open-pdf-studio/js/i18n/locales/sr/preferences.json index e66488e37..d2422a516 100644 --- a/open-pdf-studio/js/i18n/locales/sr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sr/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Отворена стрелица (обрнута)", "headClosedReversed": "Затворена стрелица (обрнута)", "headSlash": "Коса црта", - "polylineDefaults": "Подразумевано за полилинију" + "polylineDefaults": "Подразумевано за полилинију", + "smoothing": "Изглађивање", + "pressure": "Притисак оловке", + "pressureHint": "Притисак оловке мења дебљину потеза (потези мишем задржавају сталну дебљину)", + "pressureMinWidth": "Најмања ширина (%)", + "penOnly": "Само оловка (занемари додир)", + "penOnlyHint": "Додир не црта алатом слободне руке; корисно против додира длана при писању оловком" }, "shapes": { "rectangleDefaults": "Подразумевано за правоугаоник", diff --git a/open-pdf-studio/js/i18n/locales/sr/properties.json b/open-pdf-studio/js/i18n/locales/sr/properties.json index 503a3edfa..07ac59968 100644 --- a/open-pdf-studio/js/i18n/locales/sr/properties.json +++ b/open-pdf-studio/js/i18n/locales/sr/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Водоравна шрафура ретка" } }, + "ink": { + "title": "Мастило", + "pressure": "Притисак оловке", + "pressureHint": "Притисак оловке мења дебљину потеза (потези мишем задржавају сталну дебљину)", + "minWidth": "Најмања ширина (%)", + "minWidthHint": "Ширина при најлакшем притиску, као проценат дебљине линије", + "smoothing": "Изглађивање", + "smoothingHint": "Јачина изглађивања потеза (0 = без)" + }, "attachment": { "file": "Датотека", "size": "Величина", diff --git a/open-pdf-studio/js/i18n/locales/sv/preferences.json b/open-pdf-studio/js/i18n/locales/sv/preferences.json index 9558490d5..3311bc7af 100644 --- a/open-pdf-studio/js/i18n/locales/sv/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sv/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Öppen pil (omvänd)", "headClosedReversed": "Stängd pil (omvänd)", "headSlash": "Snedstreck", - "polylineDefaults": "Standardvärden för polylinje" + "polylineDefaults": "Standardvärden för polylinje", + "smoothing": "Utjämning", + "pressure": "Penntryck", + "pressureHint": "Penntrycket ändrar linjebredden (musdrag behåller en fast bredd)", + "pressureMinWidth": "Minsta bredd (%)", + "penOnly": "Endast penna (ignorera beröring)", + "penOnlyHint": "Beröring ritar inte med frihandsverktyget; användbart mot handflatan när du skriver med penna" }, "shapes": { "rectangleDefaults": "Standardvärden för rektangel", diff --git a/open-pdf-studio/js/i18n/locales/sv/properties.json b/open-pdf-studio/js/i18n/locales/sv/properties.json index a8f7b3e95..ab9a20736 100644 --- a/open-pdf-studio/js/i18n/locales/sv/properties.json +++ b/open-pdf-studio/js/i18n/locales/sv/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Vågrät gles" } }, + "ink": { + "title": "Bläck", + "pressure": "Penntryck", + "pressureHint": "Penntrycket ändrar linjebredden (musdrag behåller en fast bredd)", + "minWidth": "Minsta bredd (%)", + "minWidthHint": "Bredd vid lättaste penntryck, i procent av linjebredden", + "smoothing": "Utjämning", + "smoothingHint": "Styrka på utjämningen av draget (0 = ingen)" + }, "attachment": { "file": "Fil", "size": "Storlek", diff --git a/open-pdf-studio/js/i18n/locales/sw/preferences.json b/open-pdf-studio/js/i18n/locales/sw/preferences.json index 8e52bf97d..92110ff9a 100644 --- a/open-pdf-studio/js/i18n/locales/sw/preferences.json +++ b/open-pdf-studio/js/i18n/locales/sw/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Mshale Wazi (kinyume)", "headClosedReversed": "Mshale Uliofungwa (kinyume)", "headSlash": "Mstari wa Mkato", - "polylineDefaults": "Chaguo-msingi za Mistari Mingi" + "polylineDefaults": "Chaguo-msingi za Mistari Mingi", + "smoothing": "Ulainishaji", + "pressure": "Shinikizo la kalamu", + "pressureHint": "Shinikizo la kalamu hubadilisha upana wa mstari (mistari ya kipanya hubaki na upana usiobadilika)", + "pressureMinWidth": "Upana wa chini (%)", + "penOnly": "Kalamu tu (puuza mguso)", + "penOnlyHint": "Mguso hauchori kwa zana ya mkono huru; husaidia kuzuia kiganja kugusa skrini unapoandika kwa kalamu" }, "shapes": { "rectangleDefaults": "Chaguo-msingi za Mstatili", diff --git a/open-pdf-studio/js/i18n/locales/sw/properties.json b/open-pdf-studio/js/i18n/locales/sw/properties.json index d6ed0071e..3c1f14aa9 100644 --- a/open-pdf-studio/js/i18n/locales/sw/properties.json +++ b/open-pdf-studio/js/i18n/locales/sw/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Mistari mlalo ya mbali" } }, + "ink": { + "title": "Wino", + "pressure": "Shinikizo la kalamu", + "pressureHint": "Shinikizo la kalamu hubadilisha upana wa mstari (mistari ya kipanya hubaki na upana usiobadilika)", + "minWidth": "Upana wa chini (%)", + "minWidthHint": "Upana kwa shinikizo jepesi zaidi, kama asilimia ya unene wa mstari", + "smoothing": "Ulainishaji", + "smoothingHint": "Nguvu ya kulainisha mstari (0 = hakuna)" + }, "attachment": { "file": "Faili", "size": "Ukubwa", diff --git a/open-pdf-studio/js/i18n/locales/ta/preferences.json b/open-pdf-studio/js/i18n/locales/ta/preferences.json index 5277b98fc..0b16a8d80 100644 --- a/open-pdf-studio/js/i18n/locales/ta/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ta/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "திறந்த அம்பு (தலைகீழ்)", "headClosedReversed": "மூடிய அம்பு (தலைகீழ்)", "headSlash": "சாய்வுக்கோடு", - "polylineDefaults": "பல்கோடு இயல்புநிலைகள்" + "polylineDefaults": "பல்கோடு இயல்புநிலைகள்", + "smoothing": "மென்மையாக்கல்", + "pressure": "பேனா அழுத்தம்", + "pressureHint": "பேனா அழுத்தம் கோட்டின் அகலத்தை மாற்றும் (சுட்டி கோடுகள் நிலையான அகலத்தில் இருக்கும்)", + "pressureMinWidth": "குறைந்தபட்ச அகலம் (%)", + "penOnly": "பேனா மட்டும் (தொடுதலைப் புறக்கணி)", + "penOnlyHint": "தொடுதல் கைவரைவு கருவியால் வரையாது; பேனாவால் எழுதும்போது உள்ளங்கை தொடுவதைத் தவிர்க்க உதவும்" }, "shapes": { "rectangleDefaults": "செவ்வக இயல்புநிலைகள்", diff --git a/open-pdf-studio/js/i18n/locales/ta/properties.json b/open-pdf-studio/js/i18n/locales/ta/properties.json index 8d3b4ee96..4c7a09ebe 100644 --- a/open-pdf-studio/js/i18n/locales/ta/properties.json +++ b/open-pdf-studio/js/i18n/locales/ta/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "கிடைமட்டம் இடைவெளி" } }, + "ink": { + "title": "மை", + "pressure": "பேனா அழுத்தம்", + "pressureHint": "பேனா அழுத்தம் கோட்டின் அகலத்தை மாற்றும் (சுட்டி கோடுகள் நிலையான அகலத்தில் இருக்கும்)", + "minWidth": "குறைந்தபட்ச அகலம் (%)", + "minWidthHint": "மிக இலகுவான அழுத்தத்தில் அகலம், கோட்டு தடிமனின் சதவீதமாக", + "smoothing": "மென்மையாக்கல்", + "smoothingHint": "கோட்டை மென்மையாக்கும் வலிமை (0 = இல்லை)" + }, "attachment": { "file": "கோப்பு", "size": "அளவு", diff --git a/open-pdf-studio/js/i18n/locales/th/preferences.json b/open-pdf-studio/js/i18n/locales/th/preferences.json index e1786ab54..2012f967e 100644 --- a/open-pdf-studio/js/i18n/locales/th/preferences.json +++ b/open-pdf-studio/js/i18n/locales/th/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "ลูกศรเปิด (กลับด้าน)", "headClosedReversed": "ลูกศรปิด (กลับด้าน)", "headSlash": "เส้นทแยง", - "polylineDefaults": "ค่าเริ่มต้นเส้นหลายจุด" + "polylineDefaults": "ค่าเริ่มต้นเส้นหลายจุด", + "smoothing": "การทำให้เรียบ", + "pressure": "แรงกดปากกา", + "pressureHint": "แรงกดปากกาเปลี่ยนความกว้างของเส้น (เส้นจากเมาส์มีความกว้างคงที่)", + "pressureMinWidth": "ความกว้างต่ำสุด (%)", + "penOnly": "ปากกาเท่านั้น (ไม่สนใจการสัมผัส)", + "penOnlyHint": "การสัมผัสจะไม่วาดด้วยเครื่องมือวาดอิสระ ช่วยป้องกันฝ่ามือแตะหน้าจอขณะเขียนด้วยปากกา" }, "shapes": { "rectangleDefaults": "ค่าเริ่มต้นสี่เหลี่ยม", diff --git a/open-pdf-studio/js/i18n/locales/th/properties.json b/open-pdf-studio/js/i18n/locales/th/properties.json index cd1159edf..abe0b740e 100644 --- a/open-pdf-studio/js/i18n/locales/th/properties.json +++ b/open-pdf-studio/js/i18n/locales/th/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "เส้นแนวนอนห่าง" } }, + "ink": { + "title": "หมึก", + "pressure": "แรงกดปากกา", + "pressureHint": "แรงกดปากกาเปลี่ยนความกว้างของเส้น (เส้นจากเมาส์มีความกว้างคงที่)", + "minWidth": "ความกว้างต่ำสุด (%)", + "minWidthHint": "ความกว้างที่แรงกดเบาที่สุด เป็นเปอร์เซ็นต์ของความหนาเส้น", + "smoothing": "การทำให้เรียบ", + "smoothingHint": "ระดับการทำให้เส้นเรียบ (0 = ไม่มี)" + }, "attachment": { "file": "ไฟล์", "size": "ขนาด", diff --git a/open-pdf-studio/js/i18n/locales/tr/preferences.json b/open-pdf-studio/js/i18n/locales/tr/preferences.json index c8602eaa2..b44b14938 100644 --- a/open-pdf-studio/js/i18n/locales/tr/preferences.json +++ b/open-pdf-studio/js/i18n/locales/tr/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Açık Ok (ters)", "headClosedReversed": "Kapalı Ok (ters)", "headSlash": "Eğik Çizgi", - "polylineDefaults": "Çoklu Çizgi Varsayılanları" + "polylineDefaults": "Çoklu Çizgi Varsayılanları", + "smoothing": "Yumuşatma", + "pressure": "Kalem basıncı", + "pressureHint": "Kalem basıncı çizgi kalınlığını değiştirir (fareyle çizilen çizgiler sabit kalınlıkta kalır)", + "pressureMinWidth": "En küçük genişlik (%)", + "penOnly": "Yalnızca kalem (dokunmayı yok say)", + "penOnlyHint": "Dokunma serbest çizim aracıyla çizmez; kalemle yazarken avuç içi temasına karşı yararlıdır" }, "shapes": { "rectangleDefaults": "Dikdörtgen Varsayılanları", diff --git a/open-pdf-studio/js/i18n/locales/tr/properties.json b/open-pdf-studio/js/i18n/locales/tr/properties.json index 72acdc9b6..354d2bf9d 100644 --- a/open-pdf-studio/js/i18n/locales/tr/properties.json +++ b/open-pdf-studio/js/i18n/locales/tr/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Yatay tarama seyrek" } }, + "ink": { + "title": "Mürekkep", + "pressure": "Kalem basıncı", + "pressureHint": "Kalem basıncı çizgi kalınlığını değiştirir (fareyle çizilen çizgiler sabit kalınlıkta kalır)", + "minWidth": "En küçük genişlik (%)", + "minWidthHint": "En hafif basınçtaki genişlik, çizgi kalınlığının yüzdesi olarak", + "smoothing": "Yumuşatma", + "smoothingHint": "Çizgi yumuşatma gücü (0 = yok)" + }, "attachment": { "file": "Dosya", "size": "Boyut", diff --git a/open-pdf-studio/js/i18n/locales/uk/preferences.json b/open-pdf-studio/js/i18n/locales/uk/preferences.json index 7929c4040..ec6b7a612 100644 --- a/open-pdf-studio/js/i18n/locales/uk/preferences.json +++ b/open-pdf-studio/js/i18n/locales/uk/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Відкрита стрілка (обернена)", "headClosedReversed": "Закрита стрілка (обернена)", "headSlash": "Коса риска", - "polylineDefaults": "Типові значення полілінії" + "polylineDefaults": "Типові значення полілінії", + "smoothing": "Згладжування", + "pressure": "Натиск пера", + "pressureHint": "Натиск пера змінює товщину штриха (штрихи мишею зберігають сталу товщину)", + "pressureMinWidth": "Мінімальна ширина (%)", + "penOnly": "Лише перо (ігнорувати дотики)", + "penOnlyHint": "Дотик не малює інструментом «від руки»; допомагає проти дотику долонею під час письма пером" }, "shapes": { "rectangleDefaults": "Типові значення прямокутника", diff --git a/open-pdf-studio/js/i18n/locales/uk/properties.json b/open-pdf-studio/js/i18n/locales/uk/properties.json index 04e041a0a..b023af31f 100644 --- a/open-pdf-studio/js/i18n/locales/uk/properties.json +++ b/open-pdf-studio/js/i18n/locales/uk/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Горизонтальне штрихування рідке" } }, + "ink": { + "title": "Чорнило", + "pressure": "Натиск пера", + "pressureHint": "Натиск пера змінює товщину штриха (штрихи мишею зберігають сталу товщину)", + "minWidth": "Мінімальна ширина (%)", + "minWidthHint": "Ширина за найлегшого натиску, у відсотках від товщини лінії", + "smoothing": "Згладжування", + "smoothingHint": "Сила згладжування штриха (0 = немає)" + }, "attachment": { "file": "Файл", "size": "Розмір", diff --git a/open-pdf-studio/js/i18n/locales/ur/preferences.json b/open-pdf-studio/js/i18n/locales/ur/preferences.json index c009e36b5..e2efdebd1 100644 --- a/open-pdf-studio/js/i18n/locales/ur/preferences.json +++ b/open-pdf-studio/js/i18n/locales/ur/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "کھلا تیر (الٹا)", "headClosedReversed": "بند تیر (الٹا)", "headSlash": "سلیش", - "polylineDefaults": "پولی لائن ڈیفالٹس" + "polylineDefaults": "پولی لائن ڈیفالٹس", + "smoothing": "ہمواری", + "pressure": "قلم کا دباؤ", + "pressureHint": "قلم کا دباؤ لکیر کی چوڑائی بدلتا ہے (ماؤس کی لکیریں یکساں چوڑائی رکھتی ہیں)", + "pressureMinWidth": "کم از کم چوڑائی (%)", + "penOnly": "صرف قلم (چھونے کو نظرانداز کریں)", + "penOnlyHint": "چھونے سے فری ہینڈ ٹول سے ڈرائنگ نہیں ہوتی؛ قلم سے لکھتے وقت ہتھیلی کے لمس سے بچاؤ کے لیے مفید" }, "shapes": { "rectangleDefaults": "مستطیل ڈیفالٹس", diff --git a/open-pdf-studio/js/i18n/locales/ur/properties.json b/open-pdf-studio/js/i18n/locales/ur/properties.json index 1e612a8b2..fa94ea9fd 100644 --- a/open-pdf-studio/js/i18n/locales/ur/properties.json +++ b/open-pdf-studio/js/i18n/locales/ur/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "افقی کشادہ" } }, + "ink": { + "title": "روشنائی", + "pressure": "قلم کا دباؤ", + "pressureHint": "قلم کا دباؤ لکیر کی چوڑائی بدلتا ہے (ماؤس کی لکیریں یکساں چوڑائی رکھتی ہیں)", + "minWidth": "کم از کم چوڑائی (%)", + "minWidthHint": "قلم کے ہلکے ترین دباؤ پر چوڑائی، لکیر کی موٹائی کے فیصد کے طور پر", + "smoothing": "ہمواری", + "smoothingHint": "لکیر کو ہموار کرنے کی شدت (0 = کوئی نہیں)" + }, "attachment": { "file": "فائل", "size": "سائز", diff --git a/open-pdf-studio/js/i18n/locales/vi/preferences.json b/open-pdf-studio/js/i18n/locales/vi/preferences.json index 0c813379a..e3610a0e5 100644 --- a/open-pdf-studio/js/i18n/locales/vi/preferences.json +++ b/open-pdf-studio/js/i18n/locales/vi/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "Mũi tên mở (đảo ngược)", "headClosedReversed": "Mũi tên đóng (đảo ngược)", "headSlash": "Gạch chéo", - "polylineDefaults": "Mặc định Đường gấp khúc" + "polylineDefaults": "Mặc định Đường gấp khúc", + "smoothing": "Làm mượt", + "pressure": "Lực nhấn bút", + "pressureHint": "Lực nhấn bút thay đổi độ rộng nét (nét vẽ bằng chuột giữ độ rộng cố định)", + "pressureMinWidth": "Độ rộng tối thiểu (%)", + "penOnly": "Chỉ bút (bỏ qua cảm ứng)", + "penOnlyHint": "Chạm không vẽ bằng công cụ vẽ tự do; hữu ích để tránh lòng bàn tay chạm màn hình khi viết bằng bút" }, "shapes": { "rectangleDefaults": "Mặc định Hình chữ nhật", diff --git a/open-pdf-studio/js/i18n/locales/vi/properties.json b/open-pdf-studio/js/i18n/locales/vi/properties.json index 8ec676d0c..ac77eb016 100644 --- a/open-pdf-studio/js/i18n/locales/vi/properties.json +++ b/open-pdf-studio/js/i18n/locales/vi/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "Đường ngang thưa" } }, + "ink": { + "title": "Mực", + "pressure": "Lực nhấn bút", + "pressureHint": "Lực nhấn bút thay đổi độ rộng nét (nét vẽ bằng chuột giữ độ rộng cố định)", + "minWidth": "Độ rộng tối thiểu (%)", + "minWidthHint": "Độ rộng ở lực nhấn nhẹ nhất, tính theo phần trăm độ dày đường", + "smoothing": "Làm mượt", + "smoothingHint": "Mức làm mượt nét (0 = không)" + }, "attachment": { "file": "Tệp", "size": "Kích thước", diff --git a/open-pdf-studio/js/i18n/locales/zh/preferences.json b/open-pdf-studio/js/i18n/locales/zh/preferences.json index d16f7716f..ee4853199 100644 --- a/open-pdf-studio/js/i18n/locales/zh/preferences.json +++ b/open-pdf-studio/js/i18n/locales/zh/preferences.json @@ -106,7 +106,13 @@ "headOpenReversed": "开放箭头(反向)", "headClosedReversed": "闭合箭头(反向)", "headSlash": "斜线", - "polylineDefaults": "折线默认值" + "polylineDefaults": "折线默认值", + "smoothing": "平滑", + "pressure": "笔压", + "pressureHint": "笔压会改变笔画宽度(鼠标绘制的笔画宽度保持不变)", + "pressureMinWidth": "最小宽度 (%)", + "penOnly": "仅限手写笔(忽略触摸)", + "penOnlyHint": "触摸不会使用手绘工具绘制;可防止用手写笔书写时手掌误触" }, "shapes": { "rectangleDefaults": "矩形默认值", diff --git a/open-pdf-studio/js/i18n/locales/zh/properties.json b/open-pdf-studio/js/i18n/locales/zh/properties.json index 0b31916b2..66c6e96bc 100644 --- a/open-pdf-studio/js/i18n/locales/zh/properties.json +++ b/open-pdf-studio/js/i18n/locales/zh/properties.json @@ -844,6 +844,15 @@ "inb-horizontaal-breed": "横线 疏" } }, + "ink": { + "title": "墨迹", + "pressure": "笔压", + "pressureHint": "笔压会改变笔画宽度(鼠标绘制的笔画宽度保持不变)", + "minWidth": "最小宽度 (%)", + "minWidthHint": "最轻笔压时的宽度,占线宽的百分比", + "smoothing": "平滑", + "smoothingHint": "笔画平滑强度(0 = 无)" + }, "attachment": { "file": "文件", "size": "大小", diff --git a/open-pdf-studio/js/pdf/drukinkt-rondgang.test.mjs b/open-pdf-studio/js/pdf/drukinkt-rondgang.test.mjs new file mode 100644 index 000000000..14df6c669 --- /dev/null +++ b/open-pdf-studio/js/pdf/drukinkt-rondgang.test.mjs @@ -0,0 +1,172 @@ +// Drukgevoelige inkt (#385): opslaan en heropenen met de echte saver en de +// echte lader (rondgang.testhulp.mjs). +// +// - /InkList blijft de middellijn, /BS /W de grootste breedte: een andere +// lezer toont een gewone inktlijn met vaste breedte; +// - de appearance is een gevuld vlak (f) dat de variabele breedte toont; +// - de druk per punt, de minimale breedte en de gladheid komen terug, ook na +// een tweede opslag en op een gedraaide pagina; +// - een muisstreek (zonder druk) blijft een lijn met vaste breedte (S). + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PDFName, PDFRawStream, decodePDFRawStream } from 'pdf-lib'; +import { leegBlad, opslaan, laad, woordenboeken } from './rondgang.testhulp.mjs'; + +const basis = { + page: 1, rotation: 0, color: '#1144cc', strokeColor: '#1144cc', fillColor: null, + lineWidth: 6, borderStyle: 'solid', opacity: 1, author: 'Tester', + createdAt: '2026-10-27T10:00:00.000Z', modifiedAt: '2026-10-27T10:00:00.000Z', +}; + +function golf(n = 30) { + const path = [], pressures = []; + for (let i = 0; i < n; i++) { + path.push({ x: 100 + i * 8, y: 300 + Math.sin(i / 4) * 30 }); + pressures.push(Math.round((0.15 + 0.8 * (i / (n - 1))) * 1000) / 1000); + } + return { path, pressures }; +} + +async function rondgang(modellen, { keer = 1, rotatie = 0 } = {}) { + let bytes = await leegBlad({ rotatie }); + let huidig = modellen; + for (let i = 0; i < keer; i++) { + bytes = await opslaan(bytes, huidig); + huidig = await laad(bytes); + } + return { modellen: huidig, bytes }; +} + +async function inkDict(bytes) { + const lijst = await woordenboeken(bytes); + const inkt = lijst.filter(({ dict }) => String(dict.get(PDFName.of('Subtype'))) === '/Ink'); + assert.equal(inkt.length, 1, 'precies één /Ink'); + const ctx = lijst.doc.context; + const { dict } = inkt[0]; + const ap = ctx.lookup(dict.get(PDFName.of('AP'))); + const n = ap && ctx.lookup(ap.get(PDFName.of('N'))); + const inhoud = n ? Buffer.from(n instanceof PDFRawStream ? decodePDFRawStream(n).decode() : n.getContents()).toString('latin1') : ''; + return { dict, ctx, inhoud }; +} + +test('pen-streek: /InkList is de middellijn, de appearance een gevuld vlak, de druk komt terug', async () => { + const { path, pressures } = golf(); + const ann = { ...basis, id: 'pen', type: 'draw', path, pressures, inkMinWidth: 20, inkSmoothing: 60 }; + const { modellen, bytes } = await rondgang([ann]); + + const { dict, ctx, inhoud } = await inkDict(bytes); + const inkList = ctx.lookup(dict.get(PDFName.of('InkList'))); + assert.equal(inkList.size(), 1, 'één streek'); + assert.equal(ctx.lookup(inkList.get(0)).size(), path.length * 2, 'de middellijn: elk punt één keer'); + const bs = ctx.lookup(dict.get(PDFName.of('BS'))); + assert.equal(ctx.lookup(bs.get(PDFName.of('W'))).asNumber(), 6, 'terugval voor andere lezers: vaste breedte'); + assert.match(inhoud, /\brg\b/, 'vulkleur'); + assert.match(inhoud, /\nf\n?$/m, 'gevuld (nonzero)'); + assert.doesNotMatch(inhoud, /\bS\n/, 'geen lijn: de breedte zit in het vlak'); + assert.ok(dict.get(PDFName.of('OPS_InkPressure')), 'druk per punt in een eigen sleutel'); + + assert.equal(modellen.length, 1); + const [m] = modellen; + assert.equal(m.type, 'draw'); + assert.equal(m.path.length, path.length); + assert.deepEqual(m.pressures, pressures); + assert.equal(m.inkMinWidth, 20); + assert.equal(m.inkSmoothing, 60); + assert.equal(m.lineWidth, 6); + m.path.forEach((p, i) => { + assert.ok(Math.abs(p.x - path[i].x) < 0.01 && Math.abs(p.y - path[i].y) < 0.01, `punt ${i}`); + }); +}); + +test('twee keer opslaan: druk, gladheid en vorm blijven gelijk, geen dubbele annotatie', async () => { + const { path, pressures } = golf(20); + const ann = { ...basis, id: 'pen2', type: 'draw', path, pressures, inkMinWidth: 35, inkSmoothing: 40 }; + const een = await rondgang([ann], { keer: 1 }); + const twee = await rondgang([ann], { keer: 2 }); + assert.equal(twee.modellen.length, 1); + assert.deepEqual(twee.modellen[0].pressures, een.modellen[0].pressures); + assert.equal(twee.modellen[0].inkSmoothing, 40); + assert.equal(twee.modellen[0].inkMinWidth, 35); + const a = await inkDict(een.bytes), b = await inkDict(twee.bytes); + // Dezelfde appearance na nogmaals opslaan (op de afronding van de + // coördinaten in het bestand na). + const getallen = (t) => t.match(/-?\d+(\.\d+)?/g).map(Number); + const ga = getallen(a.inhoud), gb = getallen(b.inhoud); + assert.equal(gb.length, ga.length, 'evenveel punten'); + assert.ok(ga.every((v, i) => Math.abs(v - gb[i]) < 0.01), 'dezelfde omtrek'); + assert.equal(b.inhoud.replace(/[\d.-]+/g, '#'), a.inhoud.replace(/[\d.-]+/g, '#'), 'dezelfde operatoren'); +}); + +test('gedraaide pagina (90°): de streek en de druk komen terug waar ze getekend zijn', async () => { + const { path, pressures } = golf(16); + const ann = { ...basis, id: 'rot', type: 'draw', path, pressures, inkMinWidth: 25, inkSmoothing: 50 }; + const { modellen, bytes } = await rondgang([ann], { rotatie: 90, keer: 2 }); + assert.equal(modellen.length, 1); + const [m] = modellen; + assert.deepEqual(m.pressures, pressures); + m.path.forEach((p, i) => { + assert.ok(Math.abs(p.x - path[i].x) < 0.02 && Math.abs(p.y - path[i].y) < 0.02, `punt ${i}: ${p.x},${p.y}`); + }); + const { inhoud } = await inkDict(bytes); + assert.match(inhoud, /\nf\n?$/m); +}); + +test('druk uitgezet: blijft bewaard, de appearance is een lijn met vaste breedte', async () => { + const { path, pressures } = golf(12); + const ann = { ...basis, id: 'uit', type: 'draw', path, pressures, inkMinWidth: 30, inkSmoothing: 50, inkPressure: false }; + const { modellen, bytes } = await rondgang([ann]); + const [m] = modellen; + assert.equal(m.inkPressure, false); + assert.deepEqual(m.pressures, pressures, 'de druk zelf gaat niet verloren'); + const { inhoud } = await inkDict(bytes); + assert.match(inhoud, /^6 w$/m); + assert.match(inhoud, /\nS\n?$/m); +}); + +test('gestreepte streek: de appearance is gestreept, ook met druk (andere lezers tonen het patroon)', async () => { + const { path, pressures } = golf(16); + const ann = { ...basis, id: 'streep', type: 'draw', path, pressures, inkMinWidth: 30, inkSmoothing: 50, borderStyle: 'dashed' }; + const { modellen, bytes } = await rondgang([ann]); + const { dict, ctx, inhoud } = await inkDict(bytes); + assert.match(inhoud, /^\[3 4\] 0 d$/m, 'dash-operator in de /AP'); + assert.match(inhoud, /^6 w$/m, 'volle breedte'); + assert.match(inhoud, /\nS\n?$/m, 'getrokken, niet gevuld'); + assert.doesNotMatch(inhoud, /^f$/m); + const bs = ctx.lookup(dict.get(PDFName.of('BS'))); + assert.equal(String(bs.get(PDFName.of('S'))), '/D', '/BS blijft gestreept'); + const [m] = modellen; + assert.equal(m.borderStyle, 'dashed'); + assert.deepEqual(m.pressures, pressures, 'de druk blijft bewaard'); +}); + +test('muisstreek (geen druk): vaste breedte, gladde lijn, geen druksleutel', async () => { + const path = [{ x: 100, y: 100 }, { x: 140, y: 130 }, { x: 180, y: 110 }, { x: 220, y: 150 }]; + const ann = { ...basis, id: 'muis', type: 'draw', path, lineWidth: 3, inkSmoothing: 50 }; + const { modellen, bytes } = await rondgang([ann]); + const { dict, inhoud } = await inkDict(bytes); + assert.equal(dict.get(PDFName.of('OPS_InkPressure')), undefined); + assert.match(inhoud, /^3 w$/m, 'de lijndikte zelf'); + assert.match(inhoud, /^1 J$/m, 'ronde kappen'); + const [m] = modellen; + assert.equal(m.pressures, undefined); + assert.equal(m.inkSmoothing, 50); +}); + +test('oude streek zonder inkt-eigenschappen: blijft precies zoals hij was (geen eigen sleutels)', async () => { + const path = [{ x: 300, y: 300 }, { x: 340, y: 330 }, { x: 380, y: 310 }]; + const { modellen, bytes } = await rondgang([{ ...basis, id: 'oud', type: 'draw', path, lineWidth: 2 }]); + const { dict } = await inkDict(bytes); + for (const k of ['OPS_InkPressure', 'OPS_InkSmoothing', 'OPS_InkMinWidth']) { + assert.equal(dict.get(PDFName.of(k)), undefined, k); + } + assert.equal(modellen[0].inkSmoothing, undefined); + assert.equal(modellen[0].pressures, undefined); +}); + +test('druk die niet bij de punten past (ander programma bewerkte de streek): valt weg', async () => { + const { inktUitBestand } = await import('./loader/inkt-uit-bestand.js'); + assert.deepEqual(inktUitBestand({ inkPressures: [0.1, 0.5], inkSmoothing: 20 }, 3), { inkSmoothing: 20 }); + assert.deepEqual(inktUitBestand({ inkPressures: [0.1, 0.5, 0.9], inkMinWidth: 40 }, 3), + { pressures: [0.1, 0.5, 0.9], inkMinWidth: 40 }); +}); diff --git a/open-pdf-studio/js/pdf/loader/annotation-converter.js b/open-pdf-studio/js/pdf/loader/annotation-converter.js index eafd47e63..28cb3982f 100644 --- a/open-pdf-studio/js/pdf/loader/annotation-converter.js +++ b/open-pdf-studio/js/pdf/loader/annotation-converter.js @@ -45,6 +45,7 @@ import { ontdraaiVorm } from '../saver/vorm-draaiing.js'; import { bijlageIcoonUitPdf } from '../../annotations/bijlage-iconen.js'; import { bijlageBron } from '../bijlage-annotatie.js'; import { stempelGeometrie, kiesStempelWeergave, oudeStempelDraaiing } from './stempel-geometrie.js'; +import { inktUitBestand } from './inkt-uit-bestand.js'; /** * Zet een PDF-annotatie om naar het model van de app. @@ -667,7 +668,9 @@ async function converteerPdfAnnotatie(annot, pageNum, viewport, stampImageMap, a color: colorArrayToHex(annot.color, '#000000'), strokeColor: colorArrayToHex(annot.color, '#000000'), lineWidth: extraColors.borderWidth ?? annot.borderStyle?.width ?? 2, - borderStyle: mapBorderStyle(annot, extraColors) + borderStyle: mapBorderStyle(annot, extraColors), + // Drukgevoelige inkt (#385): alleen als de druk bij de punten past. + ...inktUitBestand(extraColors, path.length), }); } break; diff --git a/open-pdf-studio/js/pdf/loader/color-extraction.js b/open-pdf-studio/js/pdf/loader/color-extraction.js index 0644d6bc4..68a7ab91f 100644 --- a/open-pdf-studio/js/pdf/loader/color-extraction.js +++ b/open-pdf-studio/js/pdf/loader/color-extraction.js @@ -12,6 +12,7 @@ import { leesPlattegrondMeta } from './plattegrond-meta.js'; import { leesLijnSleutels } from './maatlijn-uit-bestand.js'; import { blendUitPdf } from '../../annotations/blend-modes.js'; import { leesStempelAp } from './stempel-ap.js'; +import { leesInktSleutels } from '../saver/inkt-sleutels.js'; import { leesBijlageAnnotatie } from '../bijlage-annotatie.js'; import { bewaarBijlageInhoud, bijlageItem } from '../../annotations/bijlage-store.js'; @@ -447,6 +448,9 @@ export async function extractAnnotationColors(pageNum, pdfDoc) { const locationOverride = leesPdfTekst(context, annotDict.get(PDFName.of('OPS_LocationOverride'))); if (typeof locationOverride === 'string') colors.locationOverride = locationOverride.slice(0, 1000); + // Drukgevoelige inkt (#385): druk per punt, minimale breedte, gladheid. + if (subtypeName === '/Ink') Object.assign(colors, leesInktSleutels(annotDict, context)); + // Boog: middelpunt, straal en hoeken in de app-ruimte van de ongedraaide // pagina (zie de 'arc'-tak van saver.js). /Vertices is de benadering // voor andere lezers (#619). diff --git a/open-pdf-studio/js/pdf/loader/inkt-uit-bestand.js b/open-pdf-studio/js/pdf/loader/inkt-uit-bestand.js new file mode 100644 index 000000000..b395aa5d1 --- /dev/null +++ b/open-pdf-studio/js/pdf/loader/inkt-uit-bestand.js @@ -0,0 +1,17 @@ +// Drukgevoelige inkt (#385) uit het bestand: de eigen sleutels die +// color-extraction.js las (saver/inkt-sleutels.js) naar het model. De druk +// hoort bij de punten van de eerste /InkList; past het aantal niet (een ander +// programma bewerkte de streek), dan valt de druk weg en blijft de streek +// een gewone inktlijn met vaste breedte. + +export function inktUitBestand(extra, aantalPunten) { + const uit = {}; + if (!extra) return uit; + if (Array.isArray(extra.inkPressures) && extra.inkPressures.length === aantalPunten && aantalPunten > 0) { + uit.pressures = extra.inkPressures.slice(); + if (extra.inkMinWidth !== undefined) uit.inkMinWidth = extra.inkMinWidth; + if (extra.inkPressureOff) uit.inkPressure = false; + } + if (extra.inkSmoothing !== undefined) uit.inkSmoothing = extra.inkSmoothing; + return uit; +} diff --git a/open-pdf-studio/js/pdf/saver.js b/open-pdf-studio/js/pdf/saver.js index caf0e455a..f2faf788d 100644 --- a/open-pdf-studio/js/pdf/saver.js +++ b/open-pdf-studio/js/pdf/saver.js @@ -24,6 +24,8 @@ import { schrijfMeetvlakSchaal, meetvlakRect } from './saver/meetvlak-opslaan.js import { vlakZijdeTeksten } from '../annotations/measurement.js'; import { verzamelSchaalBronnen } from '../annotations/schaal-op-punt.js'; import { attachVectorAP } from './saver/vector-ap.js'; +import { isGladdeInkt, inktAppearanceInhoud } from '../annotations/pressure-ink.js'; +import { inktSleutels } from './saver/inkt-sleutels.js'; import { MODEL_ID_SLEUTEL } from './flatten-bytes.js'; import { schaalgebiedAppearance } from './saver/scale-region-ap.js'; import { bytesVan as knipselBytesVan } from '../annotations/vector-snippet-store.js'; @@ -952,7 +954,11 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom const inkDict = { Type: 'Annot', Subtype: 'Ink', - Rect: [minX - borderWidth, minY - borderWidth, maxX + borderWidth, maxY + borderWidth], + // Een gladde streek kan iets buiten de ingevoerde punten buigen. + Rect: (() => { + const pad = borderWidth + (isGladdeInkt(ann) ? 1 : 0); + return [minX - pad, minY - pad, maxX + pad, maxY + pad]; + })(), InkList: [inkList], C: strokeColorArr, CA: opacity, @@ -964,7 +970,19 @@ async function _savePDFNu(saveAsPath, activeDoc, { alleenAnnotaties = false, mom inkDict.BS = buildBorderStyle(context, borderWidth, ann.borderStyle); + // Drukgevoelige / gladde inkt (#385): /InkList blijft de + // middellijn (andere lezers: vaste breedte /BS /W), de appearance + // tekent de gladde streek met variabele breedte als gevuld vlak. + // De druk per punt, de minimale breedte en de gladheid gaan in + // eigen sleutels mee, zodat de streek na heropenen bewerkbaar blijft. + Object.assign(inkDict, inktSleutels(ann)); + annotDict = context.obj(inkDict); + if (isGladdeInkt(ann)) { + attachVectorAP(context, annotDict, { + content: inktAppearanceInhoud(ann, convertX, convertY, hexToRgb(ann.strokeColor || ann.color || '#000000')), + }, inkDict.Rect); + } break; } diff --git a/open-pdf-studio/js/pdf/saver/inkt-sleutels.js b/open-pdf-studio/js/pdf/saver/inkt-sleutels.js new file mode 100644 index 000000000..2956aee91 --- /dev/null +++ b/open-pdf-studio/js/pdf/saver/inkt-sleutels.js @@ -0,0 +1,65 @@ +// Eigen sleutels van drukgevoelige inkt (#385), heen en terug. +// +// /InkList houdt de middellijn (de standaard; andere lezers tekenen hem met +// de vaste breedte uit /BS). Wat de app nodig heeft om de streek na +// heropenen weer precies zo te tekenen en te bewerken, gaat in eigen +// sleutels mee (andere lezers negeren ze): +// /OPS_InkPressure de pendruk 0..1 per punt van de eerste /InkList +// /OPS_InkMinWidth breedte bij druk 0, in % van de lijndikte +// /OPS_InkSmoothing gladheid 0..100 +// /OPS_InkPressureOff true: de druk staat uit (maar blijft bewaard) +// +// Alleen pdf-lib-waarden: ook in node te testen. + +import { PDFName } from 'pdf-lib'; +import { klemDruk, klemGladheid, klemMinBreedte } from '../../annotations/pressure-ink.js'; + +/** De sleutels voor het Ink-woordenboek (gewone JS-waarden voor context.obj). */ +export function inktSleutels(ann) { + const uit = {}; + if (!ann || !Array.isArray(ann.path)) return uit; + const drukGeldig = Array.isArray(ann.pressures) && ann.pressures.length === ann.path.length + && ann.pressures.length > 0; + if (drukGeldig) { + uit.OPS_InkPressure = ann.pressures.map((p) => Math.round(klemDruk(p) * 1000) / 1000); + if (ann.inkMinWidth !== undefined) uit.OPS_InkMinWidth = klemMinBreedte(ann.inkMinWidth); + if (ann.inkPressure === false) uit.OPS_InkPressureOff = true; + } + if (ann.inkSmoothing !== undefined && ann.inkSmoothing !== null) { + uit.OPS_InkSmoothing = klemGladheid(ann.inkSmoothing); + } + return uit; +} + +function getal(v) { + if (v === null || v === undefined) return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v.asNumber === 'function') return v.asNumber(); + if (typeof v.numberValue === 'number') return v.numberValue; + return null; +} + +/** + * Leest de sleutels uit een Ink-woordenboek (pdf-lib). Geeft alleen wat er + * staat; de lader legt de druk pas aan het model als het aantal punten klopt. + * @returns {{ inkPressures?: number[], inkMinWidth?: number, inkSmoothing?: number, inkPressureOff?: boolean }} + */ +export function leesInktSleutels(annotDict, context) { + const uit = {}; + const zoek = (naam) => { + const raw = annotDict.get(PDFName.of(naam)); + return raw === undefined ? undefined : (context.lookup(raw) || raw); + }; + const druk = zoek('OPS_InkPressure'); + if (druk && typeof druk.asArray === 'function') { + const reeks = druk.asArray().map((v) => getal(context.lookup(v) || v)); + if (reeks.length > 0 && reeks.every((v) => v !== null)) uit.inkPressures = reeks.map(klemDruk); + } + const min = getal(zoek('OPS_InkMinWidth')); + if (min !== null) uit.inkMinWidth = klemMinBreedte(min); + const glad = getal(zoek('OPS_InkSmoothing')); + if (glad !== null) uit.inkSmoothing = klemGladheid(glad); + const uitgezet = zoek('OPS_InkPressureOff'); + if (uitgezet === true || (uitgezet && (uitgezet.value === true || String(uitgezet) === 'true'))) uit.inkPressureOff = true; + return uit; +} diff --git a/open-pdf-studio/js/solid/components/preferences/AnnotationsTab.jsx b/open-pdf-studio/js/solid/components/preferences/AnnotationsTab.jsx index 402341a1e..8c17f6437 100644 --- a/open-pdf-studio/js/solid/components/preferences/AnnotationsTab.jsx +++ b/open-pdf-studio/js/solid/components/preferences/AnnotationsTab.jsx @@ -300,6 +300,26 @@ export default function AnnotationsTab(props) {
+
+ + +
+
+ +
+
+ + !p.drawPressure[0]()} /> +
+
+ +
diff --git a/open-pdf-studio/js/solid/components/properties-panel/InkSection.jsx b/open-pdf-studio/js/solid/components/properties-panel/InkSection.jsx new file mode 100644 index 000000000..d8c273ea6 --- /dev/null +++ b/open-pdf-studio/js/solid/components/properties-panel/InkSection.jsx @@ -0,0 +1,48 @@ +import { Show, createEffect } from 'solid-js'; +import { annotProps, sectionVis, updateAnnotProp } from '../../stores/propertiesStore.js'; +import CollapsibleSection from './CollapsibleSection.jsx'; +import PrefComboBox from '../preferences/PrefComboBox.jsx'; +import { useTranslation } from '../../../i18n/useTranslation.js'; + +// Vrije hand (#385): pendruk aan/uit, minimale breedte en gladheid. +export default function InkSection() { + const { t } = useTranslation('properties'); + const isLocked = () => annotProps.locked === true || annotProps.locked === 'mixed'; + + return ( + + +
+ + createEffect(() => { el.indeterminate = annotProps.inkPressure === 'mixed'; })} + disabled={isLocked()} + onChange={(e) => updateAnnotProp('inkPressure', e.target.checked)} /> +
+ +
+ + annotProps.inkMinWidth} + setValue={(val) => updateAnnotProp('inkMinWidth', val)} + options={[10, 20, 30, 40, 50, 75, 100]} + min={5} max={100} fallback={30} suffix="%" + disabled={() => isLocked() || annotProps.inkPressure === false} + /> +
+ +
+ + annotProps.inkSmoothing} + setValue={(val) => updateAnnotProp('inkSmoothing', val)} + options={[0, 25, 50, 75, 100]} + min={0} max={100} fallback={0} + disabled={isLocked} + /> +
+
+
+ ); +} diff --git a/open-pdf-studio/js/solid/components/properties-panel/PropertiesPanel.jsx b/open-pdf-studio/js/solid/components/properties-panel/PropertiesPanel.jsx index eb9f9757d..ef74f290f 100644 --- a/open-pdf-studio/js/solid/components/properties-panel/PropertiesPanel.jsx +++ b/open-pdf-studio/js/solid/components/properties-panel/PropertiesPanel.jsx @@ -12,6 +12,7 @@ import RepliesSection from './RepliesSection.jsx'; import AppearanceSection from './AppearanceSection.jsx'; import HatchPatternSection from './HatchPatternSection.jsx'; import LineEndingsSection from './LineEndingsSection.jsx'; +import InkSection from './InkSection.jsx'; import DimensionsSection from './DimensionsSection.jsx'; import MeasurementSection from './MeasurementSection.jsx'; import TextFormatSection from './TextFormatSection.jsx'; @@ -84,6 +85,7 @@ export default function PropertiesPanel() { + diff --git a/open-pdf-studio/js/solid/stores/propertiesStore.js b/open-pdf-studio/js/solid/stores/propertiesStore.js index 60ced5123..4c510d4a4 100644 --- a/open-pdf-studio/js/solid/stores/propertiesStore.js +++ b/open-pdf-studio/js/solid/stores/propertiesStore.js @@ -56,6 +56,7 @@ import { heeftVulDekking, zetLijnDekking } from '../../annotations/vul-dekking.j import { isWisselbaarProfiel, profielTypen, wisselProfielType } from '../../symbols/profieltype.js'; import { schaalgebiedStijl } from '../../annotations/scale-region-style.js'; import { leesGetalBegin, leesGeheelGetal } from '../../core/getal-invoer.js'; +import { inktPaneelWaarden, inktPropWaarde } from '../../annotations/pressure-ink-panel.js'; // Types whose single 'color' control IS their stroke colour and which render // via `strokeColor || color`. For these, the 'color' control must mirror onto @@ -145,6 +146,11 @@ const [annotProps, setAnnotProps] = createStore({ startHead: 'none', endHead: 'open', headSize: 12, + // Vrije hand (#385): drukgevoeligheid, minimale breedte (%), gladheid. + inkPressure: true, + inkHasPressure: false, + inkMinWidth: 30, + inkSmoothing: 0, arrowLength: '', measureScale: 0, measureUnit: '', @@ -194,6 +200,7 @@ const [sectionVis, setSectionVis] = createStore({ replies: false, appearance: false, lineEndings: false, + ink: false, dimensions: false, textFormat: false, paragraph: false, @@ -298,6 +305,7 @@ function computeSectionVisibility(type) { replies: !isScaleBar, appearance: !isScaleBar, lineEndings: lintZetLijnkoppen(type) || type === 'measureDistance' || type === 'measurePerimeter', + ink: type === 'draw', dimensions: isLineOrArrow, measurement: isMeasurement, scaleBar: isScaleBar, @@ -485,6 +493,7 @@ export function storeShowProperties(annotation) { endHead: annotation.endHead || (annotation.type === 'measureDistance' ? 'openCircle' : 'open'), headSize: annotation.headSize || 12, }), + ...inktPaneelWaarden(annotation), arrowLength: (annotation.type === 'arrow' || annotation.type === 'line') ? (() => { // Show the length in measured units (mm etc.), resolved at the @@ -652,6 +661,7 @@ export function storeHideProperties() { replies: false, appearance: false, lineEndings: false, + ink: false, dimensions: false, measurement: false, scaleBar: false, @@ -807,6 +817,10 @@ export function storeShowMultiSelection(selected) { startHead: sharedValue(selected, a => koppenVan(a).startHead, 'mixed'), endHead: sharedValue(selected, a => koppenVan(a).endHead, 'mixed'), headSize: sharedValue(selected, a => koppenVan(a).headSize, 'mixed'), + inkPressure: sharedValue(selected, a => a.inkPressure !== false, 'mixed'), + inkMinWidth: sharedValue(selected, a => inktPaneelWaarden(a).inkMinWidth, 'mixed'), + inkSmoothing: sharedValue(selected, a => inktPaneelWaarden(a).inkSmoothing, 'mixed'), + inkHasPressure: selected.some(a => Array.isArray(a.pressures) && a.pressures.length > 0), arrowLength: '', replies: [], multiCount: selected.length, @@ -832,6 +846,7 @@ export function storeShowMultiSelection(selected) { replies: false, appearance: true, lineEndings: allSameType && lintZetLijnkoppen(sharedType), + ink: allSameType && sharedType === 'draw', dimensions: false, measurement: false, textFormat: allMatch(t => textboxTypes.has(t)), @@ -944,6 +959,7 @@ export function storeShowTextEditProperties(info) { replies: false, appearance: false, lineEndings: false, + ink: false, dimensions: false, measurement: false, textFormat: true, @@ -1216,6 +1232,9 @@ function applyPropToAnnotation(ann, key, value) { // Bij een vorm met aparte vul-dekking houdt de vulling haar dekking (#685). case 'opacity': zetLijnDekking(ann, leesGeheelGetal(value) / 100); break; case 'icon': ann.icon = value; break; + case 'inkPressure': case 'inkMinWidth': case 'inkSmoothing': + Object.assign(ann, inktPropWaarde(key, value)); + break; case 'borderStyle': ann.borderStyle = value; break; case 'boxShape': ann.boxShape = value === 'rect' ? undefined : value; break; case 'cross': ann.cross = value === true || undefined; break; @@ -1707,6 +1726,10 @@ function applyAnnotProp(key, value) { case 'startHead': currentAnnotation.startHead = value; break; case 'endHead': currentAnnotation.endHead = value; break; case 'headSize': currentAnnotation.headSize = leesGeheelGetal(value); break; + case 'inkPressure': case 'inkMinWidth': case 'inkSmoothing': + Object.assign(currentAnnotation, inktPropWaarde(key, value)); + value = inktPaneelWaarden(currentAnnotation)[key]; + break; case 'measureScale': currentAnnotation.measureScale = leesGetalBegin(value) || 0; recomputeMeasureText(currentAnnotation); diff --git a/open-pdf-studio/js/tools/tool-dispatcher.js b/open-pdf-studio/js/tools/tool-dispatcher.js index 39693df8b..c010c5c40 100644 --- a/open-pdf-studio/js/tools/tool-dispatcher.js +++ b/open-pdf-studio/js/tools/tool-dispatcher.js @@ -3,6 +3,7 @@ import { state, getActiveDocument } from '../core/state.js'; import { resolvePointerCoords, buildToolContext, isModalOpen, applyToolTransform, getEffectiveScale } from './tool-context.js'; import { zetRechtopRond } from '../pdf/weergave-ruimte.js'; import { getTool } from './tool-registry.js'; +import { noteerAanwijzer } from './tools/draw-tool.js'; import { eindEventBijAfbreken } from './aanraak-gebaar.js'; import { cloneAnnotation } from '../annotations/factory.js'; import { applyResize, applyMove, applyRotation } from '../annotations/transforms.js'; @@ -207,6 +208,11 @@ export function handlePointerDown(e) { // (middelmuis-pan.js, capture op window) — nooit als gereedschapsklik. if (e.button === 1) return; + // Palmafwijzing (#385): een gereedschap kan een aanwijzer weigeren (de hand + // op het scherm terwijl de pen schrijft) — dan gebeurt er helemaal niets. + const _weiger = getTool(state.currentTool); + if (_weiger?.rejectPointer && _weiger.rejectPointer(e, state)) return; + // Dismiss context menu on any canvas click (left or right) hideMenu(); @@ -365,6 +371,15 @@ function flushWachtendeMove() { let _laatsteBeweging = null; export function handlePointerMove(e) { + // Pen in de buurt (ook zwevend): de palmafwijzing van de vrije hand (#385). + if (e.pointerType === 'pen') noteerAanwijzer(e); + // Een gereedschap dat elke meting wil (de vrije hand: alle coalesced + // monsters van een pen) krijgt het ruwe event vóór de bundeling hieronder, + // die per beeldframe alleen het laatste event verwerkt. + if (state.isDrawing) { + const _ruw = getTool(state.currentTool); + if (_ruw?.onRawPointerMove) _ruw.onRawPointerMove(e, state); + } _laatsteBeweging = e; if (state.isDragging || state.isResizing || state.isDrawing || state.isRubberBanding) { _wachtendeMove = e; diff --git a/open-pdf-studio/js/tools/tools/draw-tool.js b/open-pdf-studio/js/tools/tools/draw-tool.js index c25b22d76..7b888b834 100644 --- a/open-pdf-studio/js/tools/tools/draw-tool.js +++ b/open-pdf-studio/js/tools/tools/draw-tool.js @@ -1,51 +1,176 @@ -import { applyToolTransform } from '../tool-context.js'; +import { applyToolTransform, resolvePointerCoords } from '../tool-context.js'; +import { + aanwijzerMetDruk, breedteUitDruk, inktEigenschappen, klemDruk, klemGladheid, + maakPalmFilter, monstersVanEvent, INK_STANDAARD, +} from '../../annotations/pressure-ink.js'; /** - * Freehand draw tool + * Freehand draw tool. + * + * Drukgevoelige inkt (#385): met een pen stuurt de druk de breedte per punt; + * alle tussenliggende metingen komen binnen via getCoalescedEvents (de + * dispatcher geeft elke ruwe pointermove door via onRawPointerMove, ook als + * hij het hertekenen per beeldframe bundelt). Een muis tekent een vaste + * breedte. Palmafwijzing: zolang een pen in de buurt is (of met "alleen pen" + * altijd) begint een aanraking geen streek, en tijdens een streek telt alleen + * de aanwijzer die hem begon. */ + +const palm = maakPalmFilter(); +const nu = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +// De lopende streek, buiten de reactieve state: een pen levert honderden +// punten per seconde. +let _streek = null; // { pointerId, metDruk, punten, drukken, getekend, voorbeeld, viaRuw, canvasCtx } + +function lijnBreedte(ctx, prefs) { + return prefs.drawLineWidth || ctx.getLineWidthValue() || 2; +} + +function andereAanwijzer(e) { + return !!_streek && e?.pointerId !== undefined && _streek.pointerId !== undefined + && e.pointerId !== _streek.pointerId; +} + +/** Voor de dispatcher: elke pen-beweging (ook zweven) houdt de palmafwijzing actief. */ +export function noteerAanwijzer(e) { + palm.noteer(e, nu()); +} + +function voegMonstersToe(e) { + for (const m of monstersVanEvent(e)) { + const c = resolvePointerCoords({ clientX: m.clientX, clientY: m.clientY, target: e.target }); + if (!Number.isFinite(c.x) || !Number.isFinite(c.y)) continue; + const laatste = _streek.punten[_streek.punten.length - 1]; + if (laatste && laatste.x === c.x && laatste.y === c.y) continue; + _streek.punten.push({ x: c.x, y: c.y }); + _streek.drukken.push(klemDruk(m.pressure)); + } +} + +function tekenNieuwStuk(ctx) { + const s = _streek; + // Het canvas van de pagina waar de streek begon (doorlopende weergave: een + // palm op een andere pagina levert een ander canvas). + const canvasCtx = s?.canvasCtx || ctx.canvasCtx; + if (!s || !canvasCtx || s.punten.length < 2 || s.getekend >= s.punten.length - 1) return; + const prefs = ctx.state.preferences; + const max = lijnBreedte(ctx, prefs); + const minPct = prefs.drawPressureMinWidth ?? INK_STANDAARD.minBreedtePct; + // Voorbeeld: een eenzijdig (causaal) filter, zodat de lijn al tijdens het + // tekenen rustig is; de uiteindelijke streek wordt fasevrij gladgemaakt. + const k = (klemGladheid(prefs.drawSmoothing ?? INK_STANDAARD.gladheid) / 100) * 0.85; + // Zelfde volgorde als annotation-creators: de voorkeur van het + // gereedschap, dan pas de lintwaarde (#668). + + canvasCtx.save(); + applyToolTransform(canvasCtx); + canvasCtx.globalAlpha = (prefs.drawOpacity || 100) / 100; + canvasCtx.strokeStyle = prefs.drawStrokeColor || ctx.getColorPickerValue() || '#000000'; + canvasCtx.fillStyle = canvasCtx.strokeStyle; + canvasCtx.lineCap = 'round'; + canvasCtx.lineJoin = 'round'; + canvasCtx.beginPath(); + let vorige = s.voorbeeld; + for (let i = s.getekend + 1; i < s.punten.length; i++) { + const p = s.punten[i]; + const q = { x: vorige.x + (p.x - vorige.x) * (1 - k), y: vorige.y + (p.y - vorige.y) * (1 - k), w: max }; + if (s.metDruk) { + q.w = breedteUitDruk(s.drukken[i], max, minPct); + // Een stuk met twee breedtes: het vlak tussen twee cirkels plus de cirkel. + const dx = q.x - vorige.x, dy = q.y - vorige.y; + const len = Math.hypot(dx, dy); + if (len > 1e-6) { + const nx = -dy / len, ny = dx / len; + const r0 = vorige.w / 2, r1 = q.w / 2; + canvasCtx.moveTo(vorige.x + nx * r0, vorige.y + ny * r0); + canvasCtx.lineTo(q.x + nx * r1, q.y + ny * r1); + canvasCtx.lineTo(q.x - nx * r1, q.y - ny * r1); + canvasCtx.lineTo(vorige.x - nx * r0, vorige.y - ny * r0); + canvasCtx.closePath(); + } + canvasCtx.moveTo(q.x + q.w / 2, q.y); + canvasCtx.arc(q.x, q.y, q.w / 2, 0, Math.PI * 2); + } else { + canvasCtx.moveTo(vorige.x, vorige.y); + canvasCtx.lineTo(q.x, q.y); + } + vorige = q; + } + if (s.metDruk) canvasCtx.fill('nonzero'); + else { canvasCtx.lineWidth = max; canvasCtx.stroke(); } + canvasCtx.globalAlpha = 1; + canvasCtx.restore(); + s.voorbeeld = vorige; + s.getekend = s.punten.length - 1; +} + export const drawTool = { name: 'draw', cursor: 'crosshair', - onPointerDown(ctx) { + /** Palmafwijzing: true = de dispatcher laat deze pointerdown helemaal liggen. */ + rejectPointer(e, state) { + palm.noteer(e, nu()); + // Tijdens een streek begint een tweede aanwijzer (de hand) niets. + if (state.isDrawing && andereAanwijzer(e)) return true; + return palm.weiger(e, nu(), { alleenPen: state.preferences?.drawPenOnly === true }); + }, + + onPointerDown(ctx, e) { const { x, y, state } = ctx; + const prefs = state.preferences; + const metDruk = prefs.drawPressure !== false && aanwijzerMetDruk(e?.pointerType); + const max = lijnBreedte(ctx, prefs); state.isDrawing = true; + _streek = { + pointerId: e?.pointerId, + metDruk, + punten: [{ x, y }], + drukken: [klemDruk(e?.pressure)], + getekend: 0, + voorbeeld: { x, y, w: metDruk ? breedteUitDruk(e?.pressure, max, prefs.drawPressureMinWidth) : max }, + viaRuw: false, + canvasCtx: ctx.canvasCtx, + }; state.currentPath = [{ x, y }]; }, - onPointerMove(ctx, e) { - const { x, y, state, canvasCtx } = ctx; - if (!state.isDrawing) return; - - state.currentPath.push({ x, y }); + /** Elke ruwe pointermove, vóór de bundeling per beeldframe (alle coalesced monsters). */ + onRawPointerMove(e, state) { + if (!state.isDrawing || !_streek || andereAanwijzer(e)) return; + _streek.viaRuw = true; + voegMonstersToe(e); + }, - // Incremental draw — only the new segment - canvasCtx.save(); - applyToolTransform(canvasCtx); - const prefs = state.preferences; - // Zelfde volgorde als annotation-creators: de voorkeur van het - // gereedschap, dan pas de lintwaarde (#668). Zo past het voorbeeld bij - // wat er straks staat. - canvasCtx.strokeStyle = prefs.drawStrokeColor || ctx.getColorPickerValue() || '#000000'; - canvasCtx.lineWidth = prefs.drawLineWidth || ctx.getLineWidthValue() || 2; - canvasCtx.globalAlpha = (prefs.drawOpacity || 100) / 100; - canvasCtx.lineCap = 'round'; - canvasCtx.lineJoin = 'round'; - canvasCtx.beginPath(); - canvasCtx.moveTo(state.currentPath[state.currentPath.length - 2].x, state.currentPath[state.currentPath.length - 2].y); - canvasCtx.lineTo(x, y); - canvasCtx.stroke(); - canvasCtx.globalAlpha = 1; - canvasCtx.restore(); + onPointerMove(ctx, e) { + const { state } = ctx; + if (!state.isDrawing || !_streek) return; + // Zonder ruwe doorgifte (synthetisch event, autopan): dit event zelf. + if (!_streek.viaRuw && !andereAanwijzer(e)) { + const laatste = _streek.punten[_streek.punten.length - 1]; + if (laatste.x !== ctx.x || laatste.y !== ctx.y) { + _streek.punten.push({ x: ctx.x, y: ctx.y }); + _streek.drukken.push(klemDruk(e?.pressure)); + } + } + tekenNieuwStuk(ctx); }, onPointerUp(ctx, e) { const { state } = ctx; if (!state.isDrawing) return false; + // Een andere aanwijzer (de palm) die loslaat, beëindigt de streek niet. + if (andereAanwijzer(e)) return true; state.isDrawing = false; + const streek = _streek; + _streek = null; + const prefs = state.preferences; + if (streek) state.currentPath = streek.punten; const ann = ctx.createAnnotationFromTool('draw', state.startX, state.startY, ctx.x, ctx.y, e); if (ann) { + Object.assign(ann, inktEigenschappen(prefs, streek, ann.path.length)); const doc = state.documents[state.activeDocumentIndex]; if (doc) doc.annotations.push(ann); ctx.recordAdd(ann); diff --git a/open-pdf-studio/js/types/annotation.ts b/open-pdf-studio/js/types/annotation.ts index 14e00b437..6b4625822 100644 --- a/open-pdf-studio/js/types/annotation.ts +++ b/open-pdf-studio/js/types/annotation.ts @@ -76,6 +76,14 @@ export interface LineAnnotation extends AnnotationBase { export interface DrawAnnotation extends AnnotationBase { path: Point[]; + /** Pen pressure 0..1 per point of path (pen strokes only, #385). */ + pressures?: number[]; + /** Width at pressure 0, in percent of lineWidth (5..100). */ + inkMinWidth?: number; + /** Stroke smoothing 0..100; 0 draws the raw polyline. */ + inkSmoothing?: number; + /** false: ignore pressures and draw a constant width. */ + inkPressure?: boolean; } export interface PolylineAnnotation extends AnnotationBase { @@ -161,6 +169,10 @@ export type Annotation = AnnotationBase & { endX?: number; endY?: number; path?: Point[]; + pressures?: number[]; // Freehand: pen pressure per path point (#385) + inkMinWidth?: number; + inkSmoothing?: number; + inkPressure?: boolean; points?: Point[]; controlPoints?: Point[]; // Spline control points holes?: Point[][]; // Cutout polygons for measureArea diff --git a/open-pdf-studio/js/types/preferences.ts b/open-pdf-studio/js/types/preferences.ts index c9323a8a9..0da9f92b4 100644 --- a/open-pdf-studio/js/types/preferences.ts +++ b/open-pdf-studio/js/types/preferences.ts @@ -104,6 +104,10 @@ export interface Preferences { drawStrokeColor: string; drawLineWidth: number; drawOpacity: number; + drawPressure: boolean; + drawPressureMinWidth: number; + drawSmoothing: number; + drawPenOnly: boolean; // Line defaults lineStrokeColor: string; diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index db129dc86..537943487 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -585,3 +585,6 @@ js/symbols/templates/vloer-vorstrand.test.mjs js/symbols/templates/staalverbindingen.test.mjs js/symbols/templates/wapeningskorf-staat.test.mjs js/pdf/wapening-rondgang.test.mjs +js/annotations/pressure-ink.test.mjs +js/annotations/pressure-ink-wiring.test.mjs +js/pdf/drukinkt-rondgang.test.mjs From 90c7aa312a49eb9d42760a61a7a7115b9002314e Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Thu, 29 Oct 2026 09:57:40 +0100 Subject: [PATCH 09/13] feat(assistant): more skills for reading, measuring, marking up and CAD (#788) Eleven new assistant skills, grouped in tabs in the panel: - Reading the drawing: find on sheet, explain selection, compare sheets - Measuring and quantities: measure this, take off (schedule on the sheet), check the scale - Marking up: review sheet (numbered clouds on a "Review" layer), stamp and date, tidy markups - CAD drawings: bring in a drawing, export to CAD Skills are declarative: group, required app state (document, selection, measuring scale, two pages), input checks, the MCP tools they may use and an English guide that goes into the system prompt. A chip whose precondition fails shows the reason in the panel instead of sending. Skill messages carry the app state (page, selection, scale, date) for the model. Tests check every tool name and argument in the prompt against mcp-stdio/tools.json, the gates, the message composition and the translations in all 40 locales. Review fixes on top: - The tools a skill never calls on its own now also cover the indirect routes to saving, printing and irreversible edits: app_run_command, app_key, app_click_element, app_replace_text, app_snippet_cut, app_snippet_flatten, app_merge_pdf and app_install_extension. SKILL_TOOL_EXCEPTIONS allows the structural overview skill exactly the three it needs (copy a snippet, send it to the back, replace its own snippet), and the prompt states each exception and its single use. - Export to CAD asks before it overwrites an existing file, and always when the path is a drawing that was brought in. - The undo promise is limited to markups; changing an existing measuring scale, filling title block fields and importing CAD on an open document ask for confirmation first. - Regression tests for the forbidden list, the exceptions, the undo rule and the overwrite question. --- open-pdf-studio/js/assistant-i18n.test.mjs | 20 +- open-pdf-studio/js/assistant-skill-context.js | 80 ++++ .../js/assistant-skills-gates.test.mjs | 175 +++++++++ .../js/assistant-skills-registry.test.mjs | 222 +++++++++++ open-pdf-studio/js/assistant-skills.js | 365 +++++++++++++++++- .../js/constructieoverzicht/skill.js | 1 + .../js/i18n/locales/ar/common.json | 72 ++++ .../js/i18n/locales/bg/common.json | 72 ++++ .../js/i18n/locales/bn/common.json | 72 ++++ .../js/i18n/locales/ca/common.json | 72 ++++ .../js/i18n/locales/cs/common.json | 72 ++++ .../js/i18n/locales/da/common.json | 72 ++++ .../js/i18n/locales/de/common.json | 72 ++++ .../js/i18n/locales/el/common.json | 72 ++++ .../js/i18n/locales/en/common.json | 72 ++++ .../js/i18n/locales/es/common.json | 72 ++++ .../js/i18n/locales/fa/common.json | 72 ++++ .../js/i18n/locales/fi/common.json | 72 ++++ .../js/i18n/locales/fr/common.json | 72 ++++ .../js/i18n/locales/he/common.json | 72 ++++ .../js/i18n/locales/hi/common.json | 72 ++++ .../js/i18n/locales/hr/common.json | 72 ++++ .../js/i18n/locales/hu/common.json | 72 ++++ .../js/i18n/locales/id/common.json | 72 ++++ .../js/i18n/locales/it/common.json | 72 ++++ .../js/i18n/locales/ja/common.json | 72 ++++ .../js/i18n/locales/ko/common.json | 72 ++++ .../js/i18n/locales/lo/common.json | 72 ++++ .../js/i18n/locales/ms/common.json | 72 ++++ .../js/i18n/locales/nb/common.json | 72 ++++ .../js/i18n/locales/nl/common.json | 72 ++++ .../js/i18n/locales/pl/common.json | 72 ++++ .../js/i18n/locales/pt/common.json | 72 ++++ .../js/i18n/locales/ro/common.json | 72 ++++ .../js/i18n/locales/ru/common.json | 72 ++++ .../js/i18n/locales/sk/common.json | 72 ++++ .../js/i18n/locales/sr/common.json | 72 ++++ .../js/i18n/locales/sv/common.json | 72 ++++ .../js/i18n/locales/sw/common.json | 72 ++++ .../js/i18n/locales/ta/common.json | 72 ++++ .../js/i18n/locales/th/common.json | 72 ++++ .../js/i18n/locales/tr/common.json | 72 ++++ .../js/i18n/locales/uk/common.json | 72 ++++ .../js/i18n/locales/ur/common.json | 72 ++++ .../js/i18n/locales/vi/common.json | 72 ++++ .../js/i18n/locales/zh/common.json | 72 ++++ open-pdf-studio/js/plattegrond/skill.js | 1 + .../js/solid/components/AssistantPanel.jsx | 60 ++- open-pdf-studio/scripts/unit-testlijst.txt | 2 + open-pdf-studio/styles/assistant.css | 32 ++ 50 files changed, 3822 insertions(+), 16 deletions(-) create mode 100644 open-pdf-studio/js/assistant-skill-context.js create mode 100644 open-pdf-studio/js/assistant-skills-gates.test.mjs create mode 100644 open-pdf-studio/js/assistant-skills-registry.test.mjs diff --git a/open-pdf-studio/js/assistant-i18n.test.mjs b/open-pdf-studio/js/assistant-i18n.test.mjs index 32e5522be..73ed39ba4 100644 --- a/open-pdf-studio/js/assistant-i18n.test.mjs +++ b/open-pdf-studio/js/assistant-i18n.test.mjs @@ -5,7 +5,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - ASSISTANT_SKILLS, SKILLS_SYSTEM_PROMPT, buildSystemPrompt, languageInstruction, skillKeys, skillMessage, + ASSISTANT_SKILLS, ASSISTANT_SKILL_GROUPS, SKILL_NEED_KEYS, SKILLS_SYSTEM_PROMPT, buildSystemPrompt, languageInstruction, + skillGroupKey, skillKeys, skillMessage, } from './assistant-skills.js'; // De assistent volgt de app-taal (#452, punt 4): elke tekst die de gebruiker @@ -78,6 +79,23 @@ test('elke vaardigheid heeft naam, uitleg en tekst in alle talen; geen losse sle } }); +// De groepen en de meldingen van de poortjes (#452): elke groep heeft een +// naam, elke melding die skillBlocker/skillInputProblem kan geven bestaat, in +// alle talen; need.page houdt {{max}} en need.scale noemt de vertaalde naam +// van "Check the scale". +test('elke vaardigheidsgroep en elke melding staat in alle talen', () => { + const NEED = SKILL_NEED_KEYS.map((k) => k.replace(/^assistant\.need\./, '')).sort(); + for (const taal of TALEN) { + const common = lees(taal); + assert.deepEqual(Object.keys(common.assistant.groups).sort(), ASSISTANT_SKILL_GROUPS.map((g) => g.id).sort(), `${taal}: groepen`); + for (const g of ASSISTANT_SKILL_GROUPS) assert.ok(String(opPad(common, skillGroupKey(g)) || '').trim(), `${taal} ${g.id}`); + assert.deepEqual(Object.keys(common.assistant.need).sort(), NEED, `${taal}: meldingen`); + for (const k of SKILL_NEED_KEYS) assert.ok(String(opPad(common, k) || '').trim(), `${taal} ${k}`); + assert.ok(common.assistant.need.page.includes('{{max}}'), `${taal} need.page houdt {{max}}`); + assert.ok(common.assistant.need.scale.includes(common.assistant.skills['check-scale'].label), `${taal} need.scale noemt de vaardigheid`); + } +}); + test('een vaardigheid is data: id, teken en een Engelse instructie, geen teksten', () => { for (const skill of ASSISTANT_SKILLS) { assert.ok(skill.id && skill.icon && skill.invoke, skill.id); diff --git a/open-pdf-studio/js/assistant-skill-context.js b/open-pdf-studio/js/assistant-skill-context.js new file mode 100644 index 000000000..6811d4dec --- /dev/null +++ b/open-pdf-studio/js/assistant-skill-context.js @@ -0,0 +1,80 @@ +// The app state a skill of the assistant needs before it is sent (#452): is a +// document open, which page, what is selected, is there a measuring scale, and +// today's date. AssistantPanel.jsx reads it from the live document at the +// moment of sending; assistant-skills.js decides with it whether the skill may +// go (skillBlocker / skillInputProblem) and appends it for the model +// (skillContextNote). +// +// No app state is imported here: the caller passes the document, so the rules +// are testable under node. The scale follows the same order as a measurement +// (schaal-op-punt.js), with the scale region first, as findMeasureScale does. + +import { verzamelSchaalBronnen, schaalgebiedenOpPagina, schaalOpPuntUitBronnen, documentHeeftSchaal } from './annotations/schaal-op-punt.js'; +import { pixelsPerUnitFor } from './annotations/scale-region-scale.js'; + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); + +/** The bounding box of an annotation in page points (top-left origin), or null. */ +export function annotationBox(a) { + if (!a) return null; + if ([a.x, a.y, a.width, a.height].every(isNum)) return { x: a.x, y: a.y, width: a.width, height: a.height }; + const pts = []; + if ([a.startX, a.startY, a.endX, a.endY].every(isNum)) pts.push({ x: a.startX, y: a.startY }, { x: a.endX, y: a.endY }); + for (const list of [a.points, a.controlPoints, a.path]) { + if (Array.isArray(list)) for (const p of list) if (p && isNum(p.x) && isNum(p.y)) pts.push(p); + } + if (!pts.length && isNum(a.x) && isNum(a.y)) pts.push({ x: a.x, y: a.y }); + if (!pts.length) return null; + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; +} + +/** The scale at a point of a page: scale region first, then the other sources. */ +export function scaleAtPoint(doc, page, x, y) { + if (!doc) return null; + const bronnen = verzamelSchaalBronnen(doc); + const gebied = schaalgebiedenOpPagina(bronnen, page) + .find((r) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height); + if (gebied) { + return { pixelsPerUnit: pixelsPerUnitFor(gebied.scaleString || '1:100', gebied.units || 'mm'), unit: gebied.units || 'mm', method: 'scaleRegion' }; + } + return schaalOpPuntUitBronnen(bronnen, doc, page, x, y); +} + +/** Local date as YYYY-MM-DD. */ +export function localDate(now = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +/** + * The skill context of the active document. + * @param {object} args + * @param {object | null} args.doc the active document (getActiveDocument()) + * @param {{hasSelection?: boolean, selectedText?: string} | null} [args.textSelection] state.textSelection + * @param {Date} [args.now] + */ +export function skillContextFromDocument({ doc, textSelection = null, now = new Date() } = {}) { + const today = localDate(now); + if (!doc) return { hasDocument: false, page: 0, pageCount: 0, selection: [], selectedText: '', scale: null, hasScale: false, today }; + const page = doc.currentPage || 1; + const pageCount = doc.pdfDoc?.numPages || doc.pageCount || 0; + const chosen = Array.isArray(doc.selectedAnnotations) && doc.selectedAnnotations.length + ? doc.selectedAnnotations + : (doc.selectedAnnotation ? [doc.selectedAnnotation] : []); + const selection = chosen.filter(Boolean).map((a) => ({ id: a.id, type: a.type, page: a.page ?? page, ...(annotationBox(a) || {}) })); + const selectedText = textSelection?.hasSelection ? String(textSelection.selectedText || '') : ''; + + // The scale where it matters: at the middle of the first selected + // annotation, otherwise anywhere on the current page or in the document. + const first = selection.find((a) => isNum(a.x)); + let scale = first ? scaleAtPoint(doc, first.page, first.x + first.width / 2, first.y + first.height / 2) : null; + if (!scale && doc.measureScale && doc.measureScale.pixelsPerUnit > 0) { + scale = { pixelsPerUnit: doc.measureScale.pixelsPerUnit, unit: doc.measureScale.unit || 'mm', method: 'document' }; + } + const hasScale = !!scale || documentHeeftSchaal(doc); + return { hasDocument: true, page, pageCount, selection, selectedText, scale, hasScale, today }; +} diff --git a/open-pdf-studio/js/assistant-skills-gates.test.mjs b/open-pdf-studio/js/assistant-skills-gates.test.mjs new file mode 100644 index 000000000..91482f09a --- /dev/null +++ b/open-pdf-studio/js/assistant-skills-gates.test.mjs @@ -0,0 +1,175 @@ +// The gates and the message of the assistant skills (#452): a skill that needs +// a selection, a measuring scale or a second page says so instead of sending; +// a skill that needs input checks it; the model gets the fixed instruction +// plus the app state at the moment of sending. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + ASSISTANT_SKILLS, SKILL_NEED_KEYS, skillBlocker, skillInputProblem, skillInputRest, skillMessage, skillContextNote, + skillsInGroup, +} from './assistant-skills.js'; +import { skillContextFromDocument, annotationBox, scaleAtPoint, localDate } from './assistant-skill-context.js'; + +const skill = (id) => ASSISTANT_SKILLS.find((s) => s.id === id); +const CTX = { + hasDocument: true, page: 2, pageCount: 5, selection: [], selectedText: '', scale: null, hasScale: false, today: '2026-10-27', +}; +const ctx = (extra) => ({ ...CTX, ...extra }); +const SEL = [{ id: 'a1', type: 'cloud', page: 2, x: 10, y: 20, width: 30, height: 40 }]; +const SCALE = { pixelsPerUnit: 0.0283465, unit: 'mm', method: 'document' }; + +test('without a document a skill that requires one does not go', () => { + for (const s of ASSISTANT_SKILLS.filter((x) => (x.requires || []).includes('document'))) { + assert.deepEqual(skillBlocker(s, { hasDocument: false }), { key: 'assistant.need.document' }, s.id); + } + // The skills that existed before keep working as they did. + for (const id of ['translate', 'summarize', 'draw', 'detect-doors', 'floorplan']) assert.equal(skillBlocker(skill(id), {}), null, id); + // Bringing in a drawing can make a new document. + assert.equal(skillBlocker(skill('import-cad'), { hasDocument: false }), null); +}); + +test('explain the selection needs a selection: an annotation or text', () => { + const s = skill('explain-selection'); + assert.deepEqual(skillBlocker(s, ctx()), { key: 'assistant.need.selection' }); + assert.equal(skillBlocker(s, ctx({ selection: SEL })), null); + assert.equal(skillBlocker(s, ctx({ selectedText: 'Bedroom 2' })), null); + assert.deepEqual(skillBlocker(s, ctx({ selectedText: ' ' })), { key: 'assistant.need.selection' }); +}); + +test('measure needs a measuring scale', () => { + const s = skill('measure'); + assert.deepEqual(skillBlocker(s, ctx({ selection: SEL })), { key: 'assistant.need.scale' }); + assert.equal(skillBlocker(s, ctx({ selection: SEL, hasScale: true, scale: SCALE })), null); +}); + +test('measure: a selection or a description', () => { + const s = skill('measure'); + const withScale = ctx({ hasScale: true, scale: SCALE }); + assert.deepEqual(skillInputProblem(s, '', withScale), { key: 'assistant.need.selectionOrInput' }); + assert.equal(skillInputProblem(s, '', { ...withScale, selection: SEL }), null); + assert.equal(skillInputProblem(s, 'the living room', withScale), null); +}); + +test('compare sheets needs two pages and the number of another page', () => { + const s = skill('compare-sheets'); + assert.deepEqual(skillBlocker(s, ctx({ pageCount: 1 })), { key: 'assistant.need.twoPages' }); + assert.equal(skillBlocker(s, ctx()), null); + const page = { key: 'assistant.need.page', params: { max: 5 } }; + assert.deepEqual(skillInputProblem(s, '', ctx()), page); + assert.deepEqual(skillInputProblem(s, 'two', ctx()), page); + assert.deepEqual(skillInputProblem(s, '2', ctx()), page, 'not the current page'); + assert.deepEqual(skillInputProblem(s, '9', ctx()), page, 'beyond the last page'); + assert.equal(skillInputProblem(s, '4', ctx()), null); + assert.equal(skillInputProblem(s, 'Seite 4', ctx()), null, 'a word around the number is fine'); +}); + +test('a skill that needs input does not go with only its prefix', () => { + for (const s of ASSISTANT_SKILLS.filter((x) => x.needsInput && !x.inputKind && !x.inputOptional)) { + assert.deepEqual(skillInputProblem(s, '', ctx()), { key: 'assistant.need.input' }, s.id); + assert.equal(skillInputProblem(s, 'something', ctx()), null, s.id); + } + // The user's own text (prefix removed) is not checked. + assert.equal(skillInputProblem(skill('take-off'), null, ctx()), null); + assert.equal(skillInputRest(skill('take-off'), 'Hoeveelheden: ', 'Hoeveelheden:'), ''); + assert.equal(skillInputRest(skill('take-off'), 'Hoeveelheden: ', 'Hoeveelheden: deuren'), 'deuren'); + assert.equal(skillInputRest(skill('take-off'), 'Hoeveelheden: ', 'Tel de ramen'), null); +}); + +test('every message key of the gates is in the list the locales must have', () => { + const keys = new Set(); + const states = [{}, { hasDocument: false }, ctx(), ctx({ pageCount: 1 }), ctx({ selection: SEL }), ctx({ hasScale: true })]; + for (const s of ASSISTANT_SKILLS) { + for (const c of states) { + const b = skillBlocker(s, c); + if (b) keys.add(b.key); + for (const rest of ['', '1', 'x']) { + const p = skillInputProblem(s, rest, c); + if (p) keys.add(p.key); + } + } + } + assert.deepEqual([...keys].sort(), [...SKILL_NEED_KEYS].sort()); +}); + +test('the model gets the instruction, the input and the app state', () => { + const s = skill('find-on-sheet'); + const c = ctx({ selection: SEL, hasScale: true, scale: SCALE }); + const msg = skillMessage(s, 'Zoek op dit blad: ', 'Zoek op dit blad: Slaapkamer 2', c); + assert.equal(msg.shown, 'Zoek op dit blad: Slaapkamer 2'); + assert.ok(msg.model.startsWith(`${s.invoke}Slaapkamer 2\n\n[App context at the moment of sending]\n`)); + assert.ok(msg.model.includes('- current page: 2 of 5')); + assert.ok(msg.model.includes('- selected annotations: a1 (cloud, page 2, box x 10 y 20 w 30 h 40)')); + assert.ok(msg.model.includes('- measuring scale: 0.0283 page points per mm (from document)')); + assert.ok(msg.model.includes('- today: 2026-10-27')); + + // Without input but with a selection, measure has its own instruction. + const m = skillMessage(skill('measure'), 'Meet: ', 'Meet:', c); + assert.ok(m.model.startsWith(skill('measure').invokeWithoutInput)); + // A skill without input: shown is the localized text. + const e = skillMessage(skill('explain-selection'), 'Leg de selectie uit.', undefined, c); + assert.equal(e.shown, 'Leg de selectie uit.'); + assert.ok(e.model.startsWith(skill('explain-selection').invoke) && e.model.includes('a1 (cloud')); + // Old skills and calls without context stay as they were. + assert.equal(skillMessage(skill('translate'), 'x', undefined, c).model, skill('translate').invoke); + assert.equal(skillMessage(s, 'Zoek: ', 'Zoek: kast').model, `${s.invoke}kast`); +}); + +test('the context note says what is missing', () => { + const none = skillContextNote(ctx()); + assert.ok(none.includes('- selected annotations: none')); + assert.ok(none.includes('- measuring scale: not set')); + assert.ok(skillContextNote(ctx({ hasScale: true })).includes('set on part of the document')); + assert.ok(skillContextNote({ hasDocument: false, today: '2026-01-02' }).includes('- no document is open')); + const many = Array.from({ length: 25 }, (_, i) => ({ id: `n${i}`, type: 'box', page: 1 })); + assert.ok(skillContextNote(ctx({ selection: many })).includes('and 5 more')); + assert.ok(skillContextNote(ctx({ selectedText: 'x'.repeat(600) })).includes('…"')); +}); + +test('the context is read from the document', () => { + const doc = { + currentPage: 3, + pdfDoc: { numPages: 7 }, + annotations: [{ id: 'r', type: 'scaleRegion', page: 3, x: 0, y: 0, width: 100, height: 100, scaleString: '1:50', units: 'mm' }], + selectedAnnotations: [{ id: 'l', type: 'line', page: 3, startX: 10, startY: 10, endX: 50, endY: 30 }], + }; + const c = skillContextFromDocument({ doc, textSelection: { hasSelection: true, selectedText: 'Hall' }, now: new Date(2026, 9, 27) }); + assert.equal(c.hasDocument, true); + assert.equal(c.page, 3); + assert.equal(c.pageCount, 7); + assert.deepEqual(c.selection, [{ id: 'l', type: 'line', page: 3, x: 10, y: 10, width: 40, height: 20 }]); + assert.equal(c.selectedText, 'Hall'); + assert.equal(c.scale.method, 'scaleRegion'); + assert.ok(Math.abs(c.scale.pixelsPerUnit - 72 / 25.4 / 50) < 1e-9); + assert.equal(c.hasScale, true); + assert.equal(c.today, '2026-10-27'); + + const bare = skillContextFromDocument({ doc: { currentPage: 1, pdfDoc: { numPages: 1 }, annotations: [] } }); + assert.equal(bare.hasScale, false); + assert.deepEqual(bare.selection, []); + const scaled = skillContextFromDocument({ doc: { currentPage: 1, annotations: [], measureScale: { pixelsPerUnit: 2, unit: 'cm' } } }); + assert.deepEqual(scaled.scale, { pixelsPerUnit: 2, unit: 'cm', method: 'document' }); + assert.equal(skillContextFromDocument({ doc: null }).hasDocument, false); + assert.equal(skillContextFromDocument({ doc: { selectedAnnotation: { id: 'x', type: 'count', page: 1, x: 5, y: 6 } } }).selection[0].id, 'x'); +}); + +test('boxes and scale helpers', () => { + assert.deepEqual(annotationBox({ points: [{ x: 1, y: 2 }, { x: 5, y: 9 }] }), { x: 1, y: 2, width: 4, height: 7 }); + assert.deepEqual(annotationBox({ x: 3, y: 4 }), { x: 3, y: 4, width: 0, height: 0 }); + assert.equal(annotationBox({}), null); + assert.equal(scaleAtPoint(null, 1, 0, 0), null); + assert.equal(scaleAtPoint({ annotations: [] }, 1, 0, 0), null); + assert.equal(localDate(new Date(2026, 0, 5)), '2026-01-05'); +}); + +test('the panel shows the groups as tabs and checks before sending', () => { + const panel = readFileSync(new URL('./solid/components/AssistantPanel.jsx', import.meta.url), 'utf8').replace(/\r\n/g, '\n'); + assert.ok(panel.includes('skillsInGroup(activeGroup())'), 'only the chips of the open group'); + assert.ok(panel.includes('t(skillGroupKey(group))'), 'group names from the locales'); + assert.ok(panel.includes('const blocked = skillBlocker(skill, ctx);'), 'a chip checks before it sends or asks for input'); + assert.ok(panel.includes('skillBlocker(skill, ctx) || skillInputProblem(skill, rest, ctx)'), 'sending checks again'); + assert.ok(panel.includes('t(notice().key, notice().params)'), 'the reason is shown in the panel'); + assert.ok(panel.includes('skillMessage(skill, prompt, shown, ctx)'), 'the input skill gets the app state'); + assert.ok(skillsInGroup('measuring').length >= 3); +}); diff --git a/open-pdf-studio/js/assistant-skills-registry.test.mjs b/open-pdf-studio/js/assistant-skills-registry.test.mjs new file mode 100644 index 000000000..4fcdfd518 --- /dev/null +++ b/open-pdf-studio/js/assistant-skills-registry.test.mjs @@ -0,0 +1,222 @@ +// The assistant skills (#452) only name MCP tools the app really has, with the +// argument names of their schema. The registry is mcp-stdio/tools.json: it is +// generated from src-tauri/src/mcp_server.rs and a Rust test keeps the two +// equal, so it is the list the model behind the relay actually sees. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + ASSISTANT_SKILLS, ASSISTANT_SKILL_GROUPS, SKILLS_SYSTEM_PROMPT, SKILL_FORBIDDEN_TOOLS, SKILL_TOOL_EXCEPTIONS, buildSystemPrompt, +} from './assistant-skills.js'; +import { CONSTRUCTIEOVERZICHT_PROMPT } from './constructieoverzicht/skill.js'; +import { PLATTEGROND_PROMPT } from './plattegrond/skill.js'; + +const lees = (pad) => readFileSync(new URL(pad, import.meta.url), 'utf8').replace(/\r\n/g, '\n'); +const TOOLS = new Map(JSON.parse(lees('../../mcp-stdio/tools.json')).map((t) => [t.name, t])); +const toolNames = (text) => [...new Set([...String(text).matchAll(/\b(app_[a-z_]+)/g)].map((m) => m[1]))]; +const NEW_SKILLS = ASSISTANT_SKILLS.filter((s) => s.guide); + +// Split on top-level commas, outside {} [] () and quotes. +function splitTop(text) { + const parts = []; + let depth = 0; + let quote = false; + let cur = ''; + for (const ch of text) { + if (ch === '"') quote = !quote; + else if (!quote && '{[('.includes(ch)) depth += 1; + else if (!quote && '}])'.includes(ch)) depth -= 1; + if (ch === ',' && depth === 0 && !quote) { parts.push(cur.trim()); cur = ''; } else cur += ch; + } + if (cur.trim()) parts.push(cur.trim()); + return parts; +} + +// Calls written as app_tool(arg, arg "value", props {a, b}) — the notation of +// the skill guides. "app_tool (" with a space is prose and not parsed. +function parseCalls(text) { + const calls = []; + const re = /\b(app_[a-z_]+)\(/g; + let m; + while ((m = re.exec(text))) { + let depth = 1; + let i = re.lastIndex; + for (; i < text.length && depth; i++) { + if (text[i] === '(') depth += 1; + else if (text[i] === ')') depth -= 1; + } + const inner = text.slice(re.lastIndex, i - 1); + const args = splitTop(inner).map((seg) => { + const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(seg)?.[0] ?? seg; + const rest = seg.slice(name.length).trim(); + const str = /^"([^"]*)"$/.exec(rest)?.[1]; + const obj = /^\{(.*)\}$/s.exec(rest)?.[1]; + return { name, value: str, keys: obj != null ? splitTop(obj).map((k) => /^[A-Za-z_]\w*/.exec(k)?.[0]) : null }; + }); + calls.push({ tool: m[1], args }); + } + return calls; +} + +test('every MCP tool named in the system prompt and the skills exists', () => { + const text = [buildSystemPrompt({ code: 'en' }), ...ASSISTANT_SKILLS.map((s) => s.invoke)].join('\n'); + const named = toolNames(text); + assert.ok(named.length >= 30, `the prompt names the tools (${named.length})`); + const missing = named.filter((n) => !TOOLS.has(n)); + assert.deepEqual(missing, [], 'not in mcp-stdio/tools.json'); +}); + +test('a skill declares the tools it uses, and only existing, reversible ones', () => { + assert.ok(NEW_SKILLS.length >= 11); + for (const skill of NEW_SKILLS) { + assert.ok(Array.isArray(skill.tools) && skill.tools.length, `${skill.id}: tools`); + for (const tool of skill.tools) { + assert.ok(TOOLS.has(tool), `${skill.id}: ${tool} does not exist`); + assert.ok(!SKILL_FORBIDDEN_TOOLS.includes(tool), `${skill.id}: ${tool} is not for a skill`); + } + const used = toolNames(`${skill.invoke}\n${skill.guide}`); + for (const tool of used) assert.ok(skill.tools.includes(tool), `${skill.id}: uses ${tool} without declaring it`); + for (const tool of skill.tools) assert.ok(used.includes(tool), `${skill.id}: declares ${tool} but its guide never says how`); + assert.ok(SKILLS_SYSTEM_PROMPT.includes(skill.guide), `${skill.id}: the guide is in the system prompt`); + } + for (const tool of SKILL_FORBIDDEN_TOOLS) assert.ok(TOOLS.has(tool), `${tool} exists`); +}); + +test('every call in the guides uses the argument names and values of the tool schema', () => { + const calls = parseCalls(SKILLS_SYSTEM_PROMPT); + assert.ok(calls.length >= 40, `the guides write their calls out (${calls.length})`); + const problems = []; + for (const { tool, args } of calls) { + const schema = TOOLS.get(tool)?.inputSchema; + if (!schema) { problems.push(`${tool}: unknown`); continue; } + const props = schema.properties || {}; + const names = args.map((a) => a.name); + for (const a of args) { + if (!(a.name in props)) { problems.push(`${tool}(${a.name}): no such argument`); continue; } + const allowed = props[a.name].enum; + if (allowed && a.value != null && !allowed.includes(a.value)) problems.push(`${tool}(${a.name} "${a.value}"): not in ${allowed}`); + } + for (const req of schema.required || []) { + if (!names.includes(req)) problems.push(`${tool}: required argument ${req} missing`); + } + } + assert.deepEqual(problems, []); +}); + +// The props an annotation type needs (js/mcp-bridge.js, the description of +// app_create_annotation). A guide that names a type gives its geometry. +const GEOMETRY = { + highlight: ['x', 'y', 'width', 'height'], + cloud: ['x', 'y', 'width', 'height'], + callout: ['x', 'y', 'width', 'height'], + textbox: ['x', 'y', 'width', 'height'], + stamp: ['x', 'y', 'width', 'height'], + count: ['x', 'y'], + measureDistance: ['startX', 'startY', 'endX', 'endY'], + measureArea: ['points'], +}; + +test('app_create_annotation in the guides gives the geometry of its type', () => { + const description = TOOLS.get('app_create_annotation').description; + for (const prop of ['startX/startY/endX/endY', 'x/y/width/height', 'points:[{x,y},...]']) assert.ok(description.includes(prop)); + const creates = NEW_SKILLS.flatMap((s) => parseCalls(s.guide).filter((c) => c.tool === 'app_create_annotation')); + assert.ok(creates.length >= 10); + for (const { args } of creates) { + const type = args.find((a) => a.name === 'type').value; + assert.ok(GEOMETRY[type], `type ${type} has a known geometry`); + const keys = args.find((a) => a.name === 'props')?.keys || []; + for (const k of GEOMETRY[type]) assert.ok(keys.includes(k), `${type} needs props.${k}`); + } +}); + +test('the parser reads the call notation', () => { + assert.deepEqual(parseCalls('use app_x(a, b "c", props {d, e: 1}) and app_y()'), [ + { tool: 'app_x', args: [{ name: 'a', value: undefined, keys: null }, { name: 'b', value: 'c', keys: null }, { name: 'props', value: undefined, keys: ['d', 'e'] }] }, + { tool: 'app_y', args: [] }, + ]); + assert.deepEqual(parseCalls('app_screenshot_view (width 2000)'), [], 'prose with a space is not a call'); +}); + +test('skills are grouped; every group has skills, every skill a known group', () => { + const groups = ASSISTANT_SKILL_GROUPS.map((g) => g.id); + assert.equal(new Set(groups).size, groups.length); + for (const g of ASSISTANT_SKILL_GROUPS) assert.ok(g.icon, g.id); + for (const skill of ASSISTANT_SKILLS) assert.ok(groups.includes(skill.group), `${skill.id}: group ${skill.group}`); + for (const g of groups) assert.ok(ASSISTANT_SKILLS.some((s) => s.group === g), `group ${g} is empty`); + const REQUIRES = ['document', 'selection', 'scale', 'twoPages']; + for (const skill of ASSISTANT_SKILLS) { + for (const r of skill.requires || []) assert.ok(REQUIRES.includes(r), `${skill.id}: requires ${r}`); + } +}); + +test('the skill groups of #452 are there', () => { + const ids = ASSISTANT_SKILLS.map((s) => s.id); + for (const id of ['find-on-sheet', 'explain-selection', 'compare-sheets', 'measure', 'take-off', 'check-scale', + 'review-sheet', 'stamp-date', 'clean-up', 'import-cad', 'export-cad']) assert.ok(ids.includes(id), id); + const skill = (id) => ASSISTANT_SKILLS.find((s) => s.id === id); + assert.ok(skill('take-off').tools.includes('app_place_schedule'), 'take-off puts a schedule on the sheet'); + assert.ok(skill('check-scale').tools.includes('app_set_measure_scale')); + assert.ok(skill('import-cad').tools.includes('app_import_cad')); + assert.ok(skill('export-cad').tools.includes('app_export_cad')); + assert.deepEqual(skill('explain-selection').requires, ['document', 'selection']); + assert.ok(skill('measure').requires.includes('scale')); + assert.equal(skill('compare-sheets').inputKind, 'page'); +}); + +// Tools that save, print, delete or change the content without an undo, also +// indirectly: a ribbon command, a key (Ctrl+S, Ctrl+P) or a click reaches +// save and print as well. +const MUST_BE_FORBIDDEN = [ + 'app_save_pdf', 'app_print', 'app_print_to_pdf', 'app_close_tab', 'app_delete_annotation', + 'app_run_command', 'app_key', 'app_click_element', 'app_replace_text', + 'app_snippet_cut', 'app_snippet_flatten', 'app_merge_pdf', 'app_install_extension', +]; + +test('the forbidden tools include the indirect routes to save, print and irreversible edits', () => { + for (const tool of MUST_BE_FORBIDDEN) assert.ok(SKILL_FORBIDDEN_TOOLS.includes(tool), `${tool} is forbidden for skills`); + const prompt = buildSystemPrompt({ code: 'en' }); + const boundaries = prompt.split('\n').find((l) => l.startsWith('- Boundaries:')); + for (const tool of SKILL_FORBIDDEN_TOOLS) assert.ok(boundaries.includes(tool), `the boundaries rule names ${tool}`); +}); + +test('a forbidden tool is only allowed for the one skill that needs it, and the prompt says how', () => { + const ids = ASSISTANT_SKILLS.map((s) => s.id); + const prompt = buildSystemPrompt({ code: 'en' }); + for (const [id, tools] of Object.entries(SKILL_TOOL_EXCEPTIONS)) { + assert.ok(ids.includes(id), `${id} is a skill`); + for (const [tool, use] of Object.entries(tools)) { + assert.ok(SKILL_FORBIDDEN_TOOLS.includes(tool), `${id}: ${tool} is an exception to the forbidden list`); + assert.ok(use && use.startsWith('only '), `${id}: ${tool} names its one use`); + assert.ok(prompt.includes(`the skill "${id}" may call ${tool} ${use}`), `${id}: the prompt states the exception for ${tool}`); + } + } + // The long prompts of the structure skills name no other forbidden tool. + const prompts = { 'structural-overview': CONSTRUCTIEOVERZICHT_PROMPT, floorplan: PLATTEGROND_PROMPT }; + for (const [id, text] of Object.entries(prompts)) { + const allowed = Object.keys(SKILL_TOOL_EXCEPTIONS[id] || {}); + const forbidden = toolNames(text).filter((t) => SKILL_FORBIDDEN_TOOLS.includes(t) && !allowed.includes(t)); + assert.deepEqual(forbidden, [], `${id} uses forbidden tools without an exception`); + } + // Every exception is used by its skill; an unused exception is dropped. + for (const [id, tools] of Object.entries(SKILL_TOOL_EXCEPTIONS)) { + for (const tool of Object.keys(tools)) assert.ok(toolNames(prompts[id] || '').includes(tool), `${id} uses ${tool}`); + } +}); + +test('only markups are promised an undo; scale, title block and import ask first', () => { + const prompt = buildSystemPrompt({ code: 'en' }); + assert.ok(!/Every markup a skill places can be undone/.test(prompt), 'the old blanket claim is gone'); + const rule = prompt.split('\n').find((l) => l.startsWith('- Before a skill changes the document')); + assert.match(rule, /Every markup \(annotation\) a skill places can be undone with app_undo/); + for (const tool of ['app_set_measure_scale', 'app_titleblock', 'app_import_cad']) assert.ok(rule.includes(tool), `${tool} asks first`); + assert.match(rule, /wait for the user to confirm/); +}); + +test('export to CAD asks before it overwrites an existing file', () => { + const guide = ASSISTANT_SKILLS.find((s) => s.id === 'export-cad').guide; + assert.match(guide, /replaces an existing file/); + assert.match(guide, /ask before exporting whether a file at that path may be overwritten/); + const ask = guide.indexOf('ask before exporting'); + assert.ok(ask >= 0 && ask < guide.indexOf('app_export_cad(path'), 'the question comes before the export call'); +}); diff --git a/open-pdf-studio/js/assistant-skills.js b/open-pdf-studio/js/assistant-skills.js index 121d801e9..e973f21e9 100644 --- a/open-pdf-studio/js/assistant-skills.js +++ b/open-pdf-studio/js/assistant-skills.js @@ -12,34 +12,264 @@ // // SKILLS_SYSTEM_PROMPT teaches the model which MCP tools to use; buildSystemPrompt() // wraps it with the answer language, which follows the app language. +// +// Skills are declarative (#452): besides the fields above a skill names its +// `group` (a tab in the panel), the app state it `requires` before it may be +// sent ('document', 'selection', 'scale', 'twoPages'), how its input is checked +// (`inputKind: 'page'`, `inputOptional: 'selection'`), the MCP `tools` it may +// use and a `guide`: the English how-to that goes into the system prompt. A +// skill with `context: true` gets the app state (page, selection, scale, date) +// appended to its instruction, so the model does not have to ask for it. +// skillBlocker() and skillInputProblem() are the gates the panel applies; they +// return a locale key (common.assistant.need.*) instead of sending. import { PLATTEGROND_SKILL, PLATTEGROND_PROMPT } from './plattegrond/skill.js'; import { CONSTRUCTIEOVERZICHT_SKILL, CONSTRUCTIEOVERZICHT_PROMPT } from './constructieoverzicht/skill.js'; +/** The tabs of the skill panel, in order. Labels: common.assistant.groups.. */ +export const ASSISTANT_SKILL_GROUPS = [ + { id: 'document', icon: '📄' }, + { id: 'reading', icon: '🔎' }, + { id: 'measuring', icon: '📏' }, + { id: 'markup', icon: '✏️' }, + { id: 'structure', icon: '🏗️' }, + { id: 'cad', icon: '📐' }, +]; + +// Tools a skill never calls on its own: they are not undone like a markup +// (saving over the original, printing, closing, deleting), or they reach those +// actions indirectly (any ribbon command, a key such as Ctrl+S or Ctrl+P, a +// click on any button), change the page content itself (replace text, cut or +// flatten a snippet into the page, merge files) or change the app (install an +// extension). +export const SKILL_FORBIDDEN_TOOLS = [ + 'app_save_pdf', 'app_print', 'app_print_to_pdf', 'app_close_tab', 'app_delete_annotation', + 'app_run_command', 'app_key', 'app_click_element', 'app_replace_text', + 'app_snippet_cut', 'app_snippet_flatten', 'app_merge_pdf', 'app_install_extension', +]; + +// The few forbidden tools a skill genuinely needs, per skill id, each with the +// one use it is allowed for. The system prompt names these exceptions, so the +// model may use them for that skill only and only in that way. +export const SKILL_TOOL_EXCEPTIONS = { + 'structural-overview': { + // Copies a storey of the architect's floor plan as a vector snippet: the + // source tab is not changed, the copy is pasted as a movable annotation. + app_snippet_cut: 'only to copy a storey from the architect\'s tab (the source is not changed)', + // The pasted snippet has to lie under the markup and there is no dedicated + // tool for the drawing order. + app_run_command: 'only with command "ribbon:#arr-send-back", to put a pasted snippet under the markup', + // Replacing a snippet with a better cut-out. + app_delete_annotation: 'only for a snippet this skill pasted itself and now replaces', + }, +}; + +// The skills of #452. The instruction (`invoke`) says what to do, the `guide` +// in the system prompt says how, with the MCP tools written as calls: +// app_tool(argument, argument "value") — assistant-skills-registry.test.mjs +// checks every name and argument against mcp-stdio/tools.json. +const READING_SKILLS = [ + { + id: 'find-on-sheet', + group: 'reading', + icon: '🔎', + invoke: 'Find on this sheet, go to the page and highlight every hit. Search term: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_search_documents', 'app_get_page_text', 'app_go_to_page', 'app_create_annotation', 'app_screenshot_view'], + guide: 'Find on this sheet: call app_search_documents(query, scope "active") to learn on which pages the term occurs. ' + + 'For the exact boxes read app_get_page_text(page): every span carries a bbox in page points with the origin at the top left, the same coordinates as annotations. ' + + 'Go to the first page with hits with app_go_to_page(page) and mark every hit with app_create_annotation(type "highlight", page, props {x, y, width, height}). ' + + 'A symbol or a scanned sheet has no text: then look with app_screenshot_view(width 2000) and put a cloud around what you recognise. Report the number of hits per page.', + }, + { + id: 'explain-selection', + group: 'reading', + icon: '💬', + invoke: 'Explain the selection in plain language: what it is, what it shows and, when a measuring scale is set, its dimensions. Change nothing.', + requires: ['document', 'selection'], + context: true, + tools: ['app_get_annotation', 'app_capture_region', 'app_get_page_text'], + guide: 'Explain this selection: the app context lists the selected annotations (id, type, page, box) or the selected text. ' + + 'Read every selected annotation with app_get_annotation(id); measure annotations carry their measureText. ' + + 'Look at the drawing around the box with app_capture_region(page, x, y, width, height, dpi 150), with some margin, and read nearby labels with app_get_page_text(page). ' + + 'Describe it in plain language; give dimensions in real units only when the context reports a measuring scale. This skill only reads.', + }, + { + id: 'compare-sheets', + group: 'reading', + icon: '🔀', + invoke: 'Compare the current page with another page: name what was added, removed or moved and mark each difference on the current page. Other page: ', + needsInput: true, + inputKind: 'page', + requires: ['document', 'twoPages'], + context: true, + tools: ['app_get_page_text', 'app_capture_region', 'app_create_annotation', 'app_list_annotations'], + guide: 'Compare two sheets: read the text of both pages with app_get_page_text(page) and compare the spans by content and position (bbox). ' + + 'Look at both pages with app_capture_region(page, dpi 100) for linework and symbols without text, and compare their markups with app_list_annotations(page). ' + + 'Mark every difference on the current page with app_create_annotation(type "cloud", page, props {x, y, width, height, text}) and give it a short note: added, removed or moved. ' + + 'List the differences in the panel, one line each, and say when the pages differ in size so positions are not comparable.', + }, +]; + +const MEASURING_SKILLS = [ + { + id: 'measure', + group: 'measuring', + icon: '📏', + invoke: 'Measure this and report the result in the drawing\'s scale. What to measure: ', + invokeWithoutInput: 'Measure the selected annotations and report the result in the drawing\'s scale.', + needsInput: true, + inputOptional: 'selection', + requires: ['document', 'scale'], + context: true, + tools: ['app_get_annotation', 'app_create_annotation', 'app_get_page_text', 'app_screenshot_view', 'app_get_viewport_state'], + guide: 'Measure this: for a selected annotation read app_get_annotation(id); a measure annotation carries measureText, for another shape compute length or area from its geometry with the scale in the app context (pixelsPerUnit page points per unit). ' + + 'For a described element find it first (app_get_page_text(page), or app_screenshot_view(width 2000) with the page size from app_get_viewport_state()) and place a measurement: ' + + 'app_create_annotation(type "measureDistance", props {startX, startY, endX, endY}) for a length, app_create_annotation(type "measureArea", props {points}) for an area. The app computes measureText from the scale itself. ' + + 'Report the value with its unit and say what you measured.', + }, + { + id: 'take-off', + group: 'measuring', + icon: '🧮', + invoke: 'Take off: count or measure the elements of this kind and place the result as a schedule on the sheet, with a short summary here. Elements: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_fit_page', 'app_screenshot_view', 'app_get_viewport_state', 'app_list_annotations', 'app_create_annotation', 'app_place_schedule', 'app_get_takeoff'], + guide: 'Take off: first check with app_list_annotations(page) whether the elements are already marked. ' + + 'To count, run app_fit_page(), look with app_screenshot_view(width 2000) (screenshot pixels to page points via pageW/pageH of app_get_viewport_state()) and place one app_create_annotation(type "count", props {x, y}) on each element. ' + + 'To measure areas or lengths a measuring scale is needed: when the app context reports none, say so and suggest the skill Check the scale instead of guessing; otherwise place app_create_annotation(type "measureArea", props {points}) or (type "measureDistance", props {startX, startY, endX, endY}). ' + + 'Then put the result on the sheet with app_place_schedule(templateId "count", name, page, x, y) (templateId "area" or "length" for measurements) in free space, and read the totals back with app_get_takeoff(name). ' + + 'The result belongs on the sheet; the panel answer is a two-line summary with the totals.', + }, + { + id: 'check-scale', + group: 'measuring', + icon: '📐', + invoke: 'Check the scale of this drawing: find it from the title block or a known dimension, set it and say what it is based on.', + requires: ['document'], + context: true, + tools: ['app_titleblock', 'app_get_page_text', 'app_list_annotations', 'app_screenshot_view', 'app_set_measure_scale'], + guide: 'Check the scale: look for the scale in the title block (app_titleblock() lists its fields, such as "schaal") and in the page text (app_get_page_text(page): patterns like 1:100, SCALE 1:50, M 1:200). ' + + 'Verify it against a known dimension when there is one: a dimension text and the length of its line in page points (app_list_annotations(page), or the drawing seen with app_screenshot_view(width 2000)). ' + + 'At 1:N in millimetres one real millimetre is 72/25.4/N page points. When the app context reports no scale, set the found one with app_set_measure_scale(pixelsPerUnit, unit "mm"); when it reports a different one, name both and set the found scale only after the user confirms. ' + + 'Say what you based it on. When sources disagree (title block against a dimension, or several viewports at different scales), report that and ask before setting anything.', + }, +]; + +const MARKUP_SKILLS = [ + { + id: 'review-sheet', + group: 'markup', + icon: '🔍', + invoke: 'Review this sheet like a checker: add a cloud with a numbered comment for every point you would raise, and give a short list here. Check: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_get_page_text', 'app_screenshot_view', 'app_capture_region', 'app_get_viewport_state', 'app_list_layers', 'app_create_layer', 'app_list_annotations', 'app_create_annotation'], + guide: 'Review this sheet: read the sheet (app_get_page_text(page) for texts and their positions, app_screenshot_view(width 2000) or app_capture_region(page, x, y, width, height, dpi 150) for details, screenshot pixels to page points via pageW/pageH of app_get_viewport_state()) and check it against the instruction. ' + + 'All review markups go on one markup layer, so they can be hidden or removed together: look with app_list_layers() for a layer named "Review" and create it once with app_create_layer(name "Review", color "#e11d48") when it is missing. ' + + 'Continue the numbering of review comments already there (app_list_annotations(layer "Review")). ' + + 'For every point place app_create_annotation(type "cloud", layer "Review", props {x, y, width, height}) around the spot and app_create_annotation(type "callout", layer "Review", props {x, y, width, height, arrowX, arrowY, text}) beside it with the number and one sentence. ' + + 'Raise only points you can point at on the sheet; in the panel list them as "1. ...", one line each.', + }, + { + id: 'stamp-date', + group: 'markup', + icon: '🔖', + invoke: 'Place a stamp, a title block entry or a revision note with today\'s date. What: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_titleblock', 'app_list_annotations', 'app_get_viewport_state', 'app_create_annotation'], + guide: 'Stamp and date: use the date from the app context, never a date of your own. ' + + 'A title block entry (revision, date, status): list the fields with app_titleblock() and fill them with app_titleblock(fields, page). ' + + 'A stamp: app_create_annotation(type "stamp", props {x, y, width, height, stampName, stampSvg}) with an SVG of a bordered text and the date, placed in free space near the title block (page size from app_get_viewport_state(), existing markups from app_list_annotations(page)). ' + + 'A revision note: app_create_annotation(type "textbox", props {x, y, width, height, text}). Say where you placed it.', + }, + { + id: 'clean-up', + group: 'markup', + icon: '🧹', + invoke: 'Clean up my markups consistently, changing only what is asked. How: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_list_annotations', 'app_get_annotation', 'app_update_annotation'], + guide: 'Clean up my markups: read the markups with app_list_annotations(page) (with selected annotations in the app context, only those) and change them only with app_update_annotation(id, props): ' + + 'align (the same x or y for markups in a row or column), renumber (the numbers in their text in reading order: top to bottom, left to right) or recolour (color, strokeColor, fillColor). ' + + 'Never delete, create or move markups to another page for this; read the details with app_get_annotation(id) when the list is not enough. Report how many markups changed.', + }, +]; + +const CAD_SKILLS = [ + { + id: 'import-cad', + group: 'cad', + icon: '📥', + invoke: 'Bring in a DWG or DXF drawing as an underlay on the current page at the right scale; ask only for what cannot be derived. Drawing: ', + needsInput: true, + context: true, + tools: ['app_import_cad', 'app_titleblock', 'app_get_page_text'], + guide: 'Bring in a drawing: the user gives the path of the .dwg or .dxf file, optionally a scale and a layout. ' + + 'Without an open document use target "new"; otherwise import it on the current page with app_import_cad(path, target "underlay", scale, space). ' + + 'Take the scale from the user\'s text, else from the sheet (app_titleblock(), app_get_page_text(page)); ask only for what you cannot derive, such as a missing path. ' + + 'Report the pages that were made and every warning (missing fonts, external references).', + }, + { + id: 'export-cad', + group: 'cad', + icon: '📤', + invoke: 'Export the current page (or, with a selection, only that area) to a DXF or DWG file at the path given. Path: ', + needsInput: true, + requires: ['document'], + context: true, + tools: ['app_list_tabs', 'app_export_cad'], + guide: 'Export this back: write only to the path the user typed, which must be absolute and end in .dxf or .dwg; never to the PDF itself. ' + + 'The export reads the file on disk: when app_list_tabs() reports the active tab as modified, tell the user that unsaved changes are not included and ask them to save first. ' + + 'app_export_cad replaces an existing file at that path without asking. Unless the user already said in this conversation that the file is new or may be replaced, ask before exporting whether a file at that path may be overwritten, and wait for the answer. ' + + 'Always ask when the path is a drawing that was brought in (the .dwg or .dxf of the skill Bring in a drawing): the original drawing must not be overwritten by accident. ' + + 'Then call app_export_cad(path, format, pages "current", scaleMode "measure", annotations true); with selected annotations in the app context pass their box as area [x0, y0, x1, y1]. ' + + 'Report the files written, their object counts and warnings.', + }, +]; + export const ASSISTANT_SKILLS = [ { id: 'translate', + group: 'document', icon: '🌐', invoke: 'Translate the text of the open document into the user\'s language. If the document is already in that language, translate it into English; if the user\'s language is English too, ask which language to translate it into. Present the translation clearly.', }, { id: 'summarize', + group: 'document', icon: '📝', invoke: 'Summarise the open document or drawing concisely: what it is about, the main parts and any points that need attention.', }, { id: 'draw', + group: 'markup', icon: '✏️', invoke: 'Draw on the drawing: ', needsInput: true, }, { id: 'detect-doors', + group: 'markup', icon: '🚪', invoke: 'Look at the floor plan, recognise the doors and mark each door on the drawing with a markup and a short label.', }, + ...READING_SKILLS, + ...MEASURING_SKILLS, + ...MARKUP_SKILLS, + ...CAD_SKILLS, { id: 'structural-layout', + group: 'structure', icon: '🏗️', invoke: 'Lay out the load-bearing structure on the drawing: grid, columns on the nodes, beams on the grid lines, the span direction per floor field, level marks, position tags and a structural schedule. Brief: ', needsInput: true, @@ -54,22 +284,115 @@ export function skillKeys(skill) { return { label: `${base}.label`, hint: `${base}.hint`, prompt: `${base}.prompt` }; } +/** The locale key (namespace `common`) of a group's label. */ +export function skillGroupKey(group) { + return `assistant.groups.${typeof group === 'string' ? group : group.id}`; +} + +/** The skills of one group, in list order. */ +export function skillsInGroup(groupId) { + return ASSISTANT_SKILLS.filter((s) => s.group === groupId); +} + +/** + * What the user added after the localized prefix of a skill that needs input, + * or null when the prefix is gone (the text is then the user's own question). + */ +export function skillInputRest(skill, localizedPrompt, typed) { + const text = String(typed ?? '').trim(); + const prefix = String(localizedPrompt ?? '').trim(); + if (!prefix || !text.startsWith(prefix)) return null; + return text.slice(prefix.length).trim(); +} + +/** Every locale key the gates below can return (common.assistant.need.*). */ +export const SKILL_NEED_KEYS = ['document', 'selection', 'scale', 'input', 'selectionOrInput', 'page', 'twoPages'] + .map((k) => `assistant.need.${k}`); + +const hasSelection = (ctx) => (ctx?.selection?.length || 0) > 0 || !!String(ctx?.selectedText || '').trim(); + +/** + * Whether a skill may be sent in the app state `ctx` (see skillContextNote for + * its fields). Returns null, or { key, params } with the message to show + * instead of sending. + */ +export function skillBlocker(skill, ctx = {}) { + const requires = skill.requires || []; + if (requires.length && !ctx.hasDocument) return { key: 'assistant.need.document' }; + if (requires.includes('selection') && !hasSelection(ctx)) return { key: 'assistant.need.selection' }; + if (requires.includes('scale') && !ctx.hasScale) return { key: 'assistant.need.scale' }; + if (requires.includes('twoPages') && !((ctx.pageCount || 0) >= 2)) return { key: 'assistant.need.twoPages' }; + return null; +} + +/** + * Whether the input of a skill that needs input is complete. `rest` is what + * skillInputRest returned (null = the user's own text, which is not checked). + */ +export function skillInputProblem(skill, rest, ctx = {}) { + if (!skill.needsInput || rest == null) return null; + if (skill.inputKind === 'page') { + const max = ctx.pageCount || 0; + const match = /\d+/.exec(rest); + const page = match ? Number(match[0]) : NaN; + if (!(page >= 1 && page <= max) || page === ctx.page) return { key: 'assistant.need.page', params: { max } }; + return null; + } + if (rest) return null; + if (skill.inputOptional === 'selection') { + return (ctx.selection?.length || 0) > 0 ? null : { key: 'assistant.need.selectionOrInput' }; + } + return { key: 'assistant.need.input' }; +} + +const round = (n, digits = 4) => Number(Number(n).toFixed(digits)); + +/** + * The app state as one block for the model, appended to the instruction of a + * skill with `context: true`. `ctx` fields: hasDocument, page, pageCount, + * selection [{ id, type, page, x, y, width, height }], selectedText, + * scale { pixelsPerUnit, unit, method } | null, today ('YYYY-MM-DD'). + */ +export function skillContextNote(ctx = {}) { + const lines = []; + if (!ctx.hasDocument) lines.push('- no document is open'); + else lines.push(`- current page: ${ctx.page} of ${ctx.pageCount}`); + const sel = ctx.selection || []; + if (sel.length) { + const shown = sel.slice(0, 20).map((a) => { + const box = [a.x, a.y, a.width, a.height].every(Number.isFinite) + ? `, box x ${round(a.x, 1)} y ${round(a.y, 1)} w ${round(a.width, 1)} h ${round(a.height, 1)}` : ''; + return `${a.id} (${a.type}, page ${a.page}${box})`; + }); + lines.push(`- selected annotations: ${shown.join('; ')}${sel.length > 20 ? `; and ${sel.length - 20} more` : ''}`); + } else if (ctx.hasDocument) lines.push('- selected annotations: none'); + const text = String(ctx.selectedText || '').trim(); + if (text) lines.push(`- selected text: "${text.length > 500 ? `${text.slice(0, 500)}…` : text}"`); + if (ctx.hasDocument) { + lines.push(ctx.scale && ctx.scale.pixelsPerUnit > 0 + ? `- measuring scale: ${round(ctx.scale.pixelsPerUnit)} page points per ${ctx.scale.unit || 'mm'}${ctx.scale.method ? ` (from ${ctx.scale.method})` : ''}` + : (ctx.hasScale ? '- measuring scale: set on part of the document (scale region, viewport or scale bar)' : '- measuring scale: not set')); + } + if (ctx.today) lines.push(`- today: ${ctx.today}`); + return `\n\n[App context at the moment of sending]\n${lines.join('\n')}`; +} + /** * The message a chip click produces. `shown` is what the chat displays (in the * app language), `model` is what the model receives (the fixed instruction). * For a skill that needs input, `typed` is the full input box text: when it * still starts with the localized prefix the chip put there, that prefix is - * swapped for the instruction; otherwise the text goes as typed. + * swapped for the instruction; otherwise the text goes as typed. With `ctx` + * (the app state) a skill with `context: true` gets skillContextNote appended. */ -export function skillMessage(skill, localizedPrompt, typed) { - if (!skill.needsInput) return { shown: localizedPrompt, model: skill.invoke }; +export function skillMessage(skill, localizedPrompt, typed, ctx) { + const note = ctx && skill.context ? skillContextNote(ctx) : ''; + if (!skill.needsInput) return { shown: localizedPrompt, model: `${skill.invoke}${note}` }; const text = String(typed ?? '').trim(); - const prefix = String(localizedPrompt ?? '').trim(); - if (prefix && text.startsWith(prefix)) { - const rest = text.slice(prefix.length).trim(); - return { shown: text, model: `${skill.invoke}${rest}` }; - } - return { shown: text, model: text }; + const rest = skillInputRest(skill, localizedPrompt, typed); + if (rest == null) return { shown: text, model: text }; + const model = !rest && skill.invokeWithoutInput ? skill.invokeWithoutInput : `${skill.invoke}${rest}`; + return { shown: text, model: `${model}${note}` }; } /** English name of a language code, for the answer-language instruction. */ @@ -91,6 +414,27 @@ export function languageInstruction({ code = 'en', englishName, nativeName } = { `Texts you place on the drawing (labels, room names, titles) also go in ${name}, unless the user asks for another language.`; } +// How every skill behaves (#452: grounding, confirmation, result on the sheet, +// boundaries), followed by the guide of each skill. +const SKILL_RULES = + 'SKILLS — rules for every skill:\n' + + '- Grounding: read text and geometry from the document first (app_get_page_text for texts with their positions, app_list_annotations and app_get_annotation for markups and measurements, the measuring scale in the app context) and use the image (app_screenshot_view, app_capture_region) for what has no text, such as linework and symbols. Say when a result rests on the image only.\n' + + '- A skill message can end with an [App context at the moment of sending] block (page, selection, measuring scale, date). Use it and do not ask the user for it again.\n' + + '- Before a skill changes the document, say in one line what it will add or change; then do it. Every markup (annotation) a skill places can be undone with app_undo. ' + + 'Other changes may not be undone that way: before app_set_measure_scale replaces a measuring scale that is already set, before app_titleblock with fields and before app_import_cad on an open document, say what will change and wait for the user to confirm.\n' + + '- Result over chat: a take-off, a review or a comparison belongs on the sheet (schedule, numbered clouds); the panel answer is a short summary.\n' + + `- Boundaries: a skill never calls ${SKILL_FORBIDDEN_TOOLS.join(', ')} unless the user asks for exactly that in this conversation. app_import_cad and app_export_cad only use a file path the user typed.\n` + + skillExceptionRules(); + +/** The prompt lines for SKILL_TOOL_EXCEPTIONS: which skill may use which forbidden tool, and how. */ +function skillExceptionRules() { + return Object.entries(SKILL_TOOL_EXCEPTIONS).map(([id, tools]) => + Object.entries(tools).map(([tool, use]) => `- Exception: the skill "${id}" may call ${tool} ${use}; no other skill may.\n`).join(''), + ).join(''); +} + +const SKILL_GUIDES = ASSISTANT_SKILLS.filter((s) => s.guide).map((s) => `- ${s.guide}\n`).join(''); + export const SKILLS_SYSTEM_PROMPT = 'You have a skill set and can carry out ACTIONS on the open PDF document through the app\'s MCP tools:\n' + '- Translating / summarising: use app_screenshot_view (width 2000) to view and read the page; return the result as text.\n' + @@ -98,6 +442,9 @@ export const SKILLS_SYSTEM_PROMPT = '- Detecting doors: first run app_fit_page, then take app_screenshot_view (width 2000), recognise the doors visually and mark each door with app_create_annotation (for example a box or cloud around the door plus a textbox label). Convert screenshot pixels to page points via pageW/pageH.\n' + 'Answer concisely and practically. Carry out requested actions directly and briefly report what you did.\n' + '\n' + + SKILL_RULES + + SKILL_GUIDES + + '\n' + 'STRUCTURAL DRAWING — laying out the load-bearing structure:\n' + '- Use app_structural_layout. In ONE undo step it places the whole plan: grid (letters and numbers, with bubbles), columns on the grid nodes, beams on the grid lines, per floor field a span arrow with the actual span, level marks, tags (position number / profile / level) and a structural schedule grouped by IFC category. NEVER build such a plan from separate app_create_annotation calls: the dimensions will be wrong and it cannot be undone in one step.\n' + '- Dimensions go in as REAL MILLIMETERS: baysX/baysY, for example "3x5400" or [5400,5400,6000]. The app converts them to page points at `scale` (default "1:100") and calibrates the measuring scale at once, so measurements and app_get_takeoff work in the same reality. NEVER convert millimeters to points yourself.\n' + diff --git a/open-pdf-studio/js/constructieoverzicht/skill.js b/open-pdf-studio/js/constructieoverzicht/skill.js index 43f0976d5..06a9e9cd9 100644 --- a/open-pdf-studio/js/constructieoverzicht/skill.js +++ b/open-pdf-studio/js/constructieoverzicht/skill.js @@ -14,6 +14,7 @@ export const CONSTRUCTIEOVERZICHT_SKILL = { id: 'structural-overview', + group: 'structure', icon: '📐', // Naam, uitleg en de tekst in het invoervak staan per taal in de locales // (common.assistant.skills.structural-overview); de instructie voor het diff --git a/open-pdf-studio/js/i18n/locales/ar/common.json b/open-pdf-studio/js/i18n/locales/ar/common.json index 92bd62ff3..a51bd76ba 100644 --- a/open-pdf-studio/js/i18n/locales/ar/common.json +++ b/open-pdf-studio/js/i18n/locales/ar/common.json @@ -124,7 +124,79 @@ "label": "مسقط أفقي", "hint": "رسم مسقط أفقي معماري: الجدران، الأبواب والنوافذ، الغرف، الأبعاد والتجهيزات (الصحية، المطبخ)", "prompt": "ارسم مسقطًا أفقيًا معماريًا. ما المطلوب رسمه: " + }, + "find-on-sheet": { + "label": "البحث في اللوحة", + "hint": "البحث عن مصطلح (اسم غرفة، علامة مقطع، رمز) والانتقال إلى صفحته وتمييز النتائج", + "prompt": "ابحث في هذه اللوحة: " + }, + "explain-selection": { + "label": "شرح التحديد", + "hint": "وصف المنطقة أو النص أو العلامة المحددة بلغة بسيطة، مع الأبعاد عند تعيين مقياس قياس", + "prompt": "اشرح التحديد." + }, + "compare-sheets": { + "label": "مقارنة اللوحات", + "hint": "تسمية ما أُضيف أو حُذف أو نُقل مقارنةً بصفحة أخرى وتمييزه", + "prompt": "قارن هذه الصفحة بالصفحة: " + }, + "measure": { + "label": "قياس هذا", + "hint": "قياس الشكل المحدد أو عنصر موصوف وعرض النتيجة بمقياس المخطط", + "prompt": "قِس: " + }, + "take-off": { + "label": "حصر الكميات", + "hint": "عدّ أو قياس عناصر من نوع واحد ووضع النتيجة كجدول على اللوحة", + "prompt": "احصر الكميات: " + }, + "check-scale": { + "label": "التحقق من المقياس", + "hint": "استخراج المقياس من جدول العنوان أو من بُعد معروف وتعيينه", + "prompt": "تحقّق من مقياس هذا المخطط." + }, + "review-sheet": { + "label": "مراجعة اللوحة", + "hint": "إضافة سحب مع تعليقات مرقّمة للنقاط التي سيثيرها المدقق", + "prompt": "راجع هذه اللوحة من حيث: " + }, + "stamp-date": { + "label": "ختم وتأريخ", + "hint": "وضع ختم أو ملء حقل في جدول العنوان أو إضافة ملاحظة مراجعة بتاريخ اليوم", + "prompt": "اختم وأرّخ: " + }, + "clean-up": { + "label": "ترتيب العلامات", + "hint": "محاذاة العلامات الموجودة أو إعادة ترقيمها أو تلوينها بشكل متّسق", + "prompt": "رتّب علاماتي: " + }, + "import-cad": { + "label": "استيراد مخطط", + "hint": "استيراد مخطط DWG أو DXF كطبقة أساس بالمقياس الصحيح", + "prompt": "استورد المخطط (مسار ملف DWG أو DXF): " + }, + "export-cad": { + "label": "تصدير إلى CAD", + "hint": "كتابة هذه الصفحة أو المنطقة المحددة في ملف DXF أو DWG", + "prompt": "صدّر هذه الصفحة إلى (مسار ملف DXF أو DWG): " } + }, + "groups": { + "document": "الترجمة والتلخيص", + "reading": "قراءة المخطط", + "measuring": "القياس والكميات", + "markup": "وضع العلامات", + "structure": "الهيكل الإنشائي والمسقط الأفقي", + "cad": "مخططات CAD" + }, + "need": { + "document": "افتح ملف PDF أولًا: تعمل هذه المهارة على المستند المفتوح.", + "selection": "حدّد أولًا علامة أو جزءًا من النص على المخطط، ثم انقر على المهارة مرة أخرى.", + "scale": "لا يحتوي هذا المخطط على مقياس قياس بعد. عيّن مقياسًا أولًا (مثلًا باستخدام \"التحقق من المقياس\") ثم حاول مرة أخرى.", + "input": "أكمل النص في مربع الإدخال بعد النقطتين، ثم أرسله.", + "selectionOrInput": "حدّد شكلًا على المخطط، أو صِف بعد النقطتين ما يجب قياسه.", + "page": "أدخل رقم الصفحة الأخرى (من 1 إلى {{max}})، وليس الصفحة الحالية.", + "twoPages": "يحتوي هذا المستند على صفحة واحدة فقط. للمقارنة بملف آخر، استبدل النص في مربع الإدخال بسؤالك الخاص." } }, "onlineSymbolLibrary": "مكتبة الرموز عبر الإنترنت", diff --git a/open-pdf-studio/js/i18n/locales/bg/common.json b/open-pdf-studio/js/i18n/locales/bg/common.json index 53b192265..dcbdc84c3 100644 --- a/open-pdf-studio/js/i18n/locales/bg/common.json +++ b/open-pdf-studio/js/i18n/locales/bg/common.json @@ -124,7 +124,79 @@ "label": "Етажен план", "hint": "Чертане на архитектурен етажен план: стени, дограма, помещения, размери и обзавеждане (санитария, кухня)", "prompt": "Начертай архитектурен етажен план. Какво да се начертае: " + }, + "find-on-sheet": { + "label": "Търси в листа", + "hint": "Намиране на термин (име на помещение, маркер на разрез, символ), преход към страницата му и открояване на резултатите", + "prompt": "Търси в този лист: " + }, + "explain-selection": { + "label": "Обясни избраното", + "hint": "Описание на избраната област, текст или маркировка на разбираем език, с размери при зададен мащаб за измерване", + "prompt": "Обясни избраното." + }, + "compare-sheets": { + "label": "Сравни листове", + "hint": "Назоваване и маркиране на добавеното, премахнатото или преместеното спрямо друга страница", + "prompt": "Сравни тази страница със страница: " + }, + "measure": { + "label": "Измери това", + "hint": "Измерване на избраната фигура или на описан елемент и представяне в мащаба на чертежа", + "prompt": "Измери: " + }, + "take-off": { + "label": "Количествена сметка", + "hint": "Преброяване или измерване на елементи от един вид и поставяне на резултата като спецификация върху листа", + "prompt": "Направи количествена сметка за: " + }, + "check-scale": { + "label": "Провери мащаба", + "hint": "Определяне на мащаба от антетката или от известен размер и задаването му", + "prompt": "Провери мащаба на този чертеж." + }, + "review-sheet": { + "label": "Прегледай листа", + "hint": "Добавяне на облачета с номерирани коментари за точките, които би посочил проверяващ", + "prompt": "Прегледай този лист за: " + }, + "stamp-date": { + "label": "Печат и дата", + "hint": "Поставяне на печат, попълване на поле в антетката или добавяне на бележка за ревизия с днешна дата", + "prompt": "Постави печат и дата: " + }, + "clean-up": { + "label": "Подреди маркировките", + "hint": "Еднакво подравняване, преномериране или преоцветяване на съществуващите маркировки", + "prompt": "Подреди маркировките ми: " + }, + "import-cad": { + "label": "Внеси чертеж", + "hint": "Импортиране на DWG или DXF чертеж като подложка в правилния мащаб", + "prompt": "Внеси чертежа (път до DWG или DXF файла): " + }, + "export-cad": { + "label": "Експорт към CAD", + "hint": "Записване на тази страница или на избраната област в DXF или DWG файл", + "prompt": "Експортирай тази страница в (път до DXF или DWG файла): " } + }, + "groups": { + "document": "Превод и обобщение", + "reading": "Разчитане на чертежа", + "measuring": "Измерване и количества", + "markup": "Маркиране", + "structure": "Конструкция и етажен план", + "cad": "CAD чертежи" + }, + "need": { + "document": "Първо отворете PDF: това умение работи с отворения документ.", + "selection": "Първо изберете маркировка или част от текста на чертежа, след което щракнете отново върху умението.", + "scale": "Този чертеж все още няма мащаб за измерване. Първо задайте такъв (например с „Провери мащаба“) и опитайте отново.", + "input": "Допълнете текста в полето за въвеждане след двоеточието и го изпратете.", + "selectionOrInput": "Изберете фигура на чертежа или опишете след двоеточието какво да се измери.", + "page": "Въведете номера на другата страница (от 1 до {{max}}), а не на текущата.", + "twoPages": "Този документ има само една страница. За да сравните с друг файл, заменете текста в полето за въвеждане със свой въпрос." } }, "onlineSymbolLibrary": "Онлайн библиотека със символи", diff --git a/open-pdf-studio/js/i18n/locales/bn/common.json b/open-pdf-studio/js/i18n/locales/bn/common.json index bf9d76dde..ce2a31e71 100644 --- a/open-pdf-studio/js/i18n/locales/bn/common.json +++ b/open-pdf-studio/js/i18n/locales/bn/common.json @@ -124,7 +124,79 @@ "label": "ফ্লোর প্ল্যান", "hint": "একটি স্থাপত্য ফ্লোর প্ল্যান আঁকুন: দেয়াল, দরজা-জানালা, কক্ষ, মাপ এবং সরঞ্জাম (স্যানিটারি, রান্নাঘর)", "prompt": "একটি স্থাপত্য ফ্লোর প্ল্যান আঁকুন। কী আঁকতে হবে: " + }, + "find-on-sheet": { + "label": "শিটে খুঁজুন", + "hint": "একটি শব্দ খুঁজুন (কক্ষের নাম, সেকশন চিহ্ন, প্রতীক), তার পৃষ্ঠায় যান এবং ফলাফলগুলো হাইলাইট করুন", + "prompt": "এই শিটে খুঁজুন: " + }, + "explain-selection": { + "label": "নির্বাচন ব্যাখ্যা", + "hint": "নির্বাচিত এলাকা, লেখা বা চিহ্ন সহজ ভাষায় বর্ণনা করুন, পরিমাপের স্কেল সেট থাকলে মাপসহ", + "prompt": "নির্বাচনটি ব্যাখ্যা করুন।" + }, + "compare-sheets": { + "label": "শিট তুলনা", + "hint": "অন্য একটি পৃষ্ঠার তুলনায় কী যোগ, বাদ বা সরানো হয়েছে তা উল্লেখ করে চিহ্নিত করুন", + "prompt": "এই পৃষ্ঠাটিকে যে পৃষ্ঠার সঙ্গে তুলনা করবেন: " + }, + "measure": { + "label": "এটি মাপুন", + "hint": "নির্বাচিত আকৃতি বা বর্ণিত উপাদান মাপুন এবং অঙ্কনের স্কেলে জানান", + "prompt": "মাপুন: " + }, + "take-off": { + "label": "পরিমাণ নিরূপণ", + "hint": "এক ধরনের উপাদান গণনা বা পরিমাপ করুন এবং ফলাফল শিটে একটি তালিকা হিসেবে বসান", + "prompt": "পরিমাণ নিরূপণ করুন: " + }, + "check-scale": { + "label": "স্কেল যাচাই করুন", + "hint": "টাইটেল ব্লক বা জানা কোনো মাপ থেকে স্কেল বের করে সেট করুন", + "prompt": "এই অঙ্কনের স্কেল যাচাই করুন।" + }, + "review-sheet": { + "label": "শিট পর্যালোচনা", + "hint": "একজন পরীক্ষক যে বিষয়গুলো তুলতেন সেগুলোর জন্য নম্বরযুক্ত মন্তব্যসহ মেঘচিহ্ন যোগ করুন", + "prompt": "এই শিটটি এই বিষয়ে পর্যালোচনা করুন: " + }, + "stamp-date": { + "label": "সিল ও তারিখ", + "hint": "একটি সিল বসান, টাইটেল ব্লকের একটি ঘর পূরণ করুন বা আজকের তারিখসহ সংশোধনী নোট যোগ করুন", + "prompt": "সিল ও তারিখ দিন: " + }, + "clean-up": { + "label": "চিহ্ন গোছান", + "hint": "বিদ্যমান চিহ্নগুলো সামঞ্জস্যপূর্ণভাবে সারিবদ্ধ, পুনরায় নম্বরযুক্ত বা পুনরায় রঙ করুন", + "prompt": "আমার চিহ্নগুলো গুছিয়ে দিন: " + }, + "import-cad": { + "label": "অঙ্কন আনুন", + "hint": "সঠিক স্কেলে একটি DWG বা DXF অঙ্কন আন্ডারলে হিসেবে আমদানি করুন", + "prompt": "অঙ্কনটি আনুন (DWG বা DXF ফাইলের পাথ): " + }, + "export-cad": { + "label": "CAD-এ রপ্তানি", + "hint": "এই পৃষ্ঠা বা নির্বাচিত এলাকা একটি DXF বা DWG ফাইলে লিখুন", + "prompt": "এই পৃষ্ঠাটি রপ্তানি করুন (DXF বা DWG ফাইলের পাথ): " } + }, + "groups": { + "document": "অনুবাদ ও সারাংশ", + "reading": "অঙ্কন পড়া", + "measuring": "পরিমাপ ও পরিমাণ", + "markup": "চিহ্নিতকরণ", + "structure": "কাঠামো ও ফ্লোর প্ল্যান", + "cad": "CAD অঙ্কন" + }, + "need": { + "document": "প্রথমে একটি PDF খুলুন: এই দক্ষতা খোলা নথিতে কাজ করে।", + "selection": "প্রথমে অঙ্কনে একটি চিহ্ন বা কিছু লেখা নির্বাচন করুন, তারপর আবার দক্ষতাটিতে ক্লিক করুন।", + "scale": "এই অঙ্কনে এখনো কোনো পরিমাপের স্কেল নেই। প্রথমে একটি সেট করুন (যেমন \"স্কেল যাচাই করুন\" দিয়ে) এবং আবার চেষ্টা করুন।", + "input": "ইনপুট বক্সে কোলনের পরে লেখাটি সম্পূর্ণ করুন, তারপর পাঠান।", + "selectionOrInput": "অঙ্কনে একটি আকৃতি নির্বাচন করুন, অথবা কোলনের পরে বর্ণনা করুন কী মাপতে হবে।", + "page": "অন্য পৃষ্ঠার নম্বর লিখুন (1 থেকে {{max}}), বর্তমান পৃষ্ঠার নয়।", + "twoPages": "এই নথিতে মাত্র একটি পৃষ্ঠা আছে। অন্য ফাইলের সঙ্গে তুলনা করতে, ইনপুট বক্সের লেখাটি আপনার নিজের প্রশ্ন দিয়ে বদলে দিন।" } }, "onlineSymbolLibrary": "অনলাইন প্রতীক লাইব্রেরি", diff --git a/open-pdf-studio/js/i18n/locales/ca/common.json b/open-pdf-studio/js/i18n/locales/ca/common.json index a8c8f0b29..615c52f16 100644 --- a/open-pdf-studio/js/i18n/locales/ca/common.json +++ b/open-pdf-studio/js/i18n/locales/ca/common.json @@ -124,7 +124,79 @@ "label": "Planta", "hint": "Dibuixa una planta arquitectònica: murs, fusteries, estances, cotes i equipament (sanitaris, cuina)", "prompt": "Dibuixa una planta arquitectònica. Què cal dibuixar: " + }, + "find-on-sheet": { + "label": "Cerca al full", + "hint": "Cerca un terme (un nom d'estança, una marca de secció, un símbol), ves a la seva pàgina i ressalta les coincidències", + "prompt": "Cerca en aquest full: " + }, + "explain-selection": { + "label": "Explica la selecció", + "hint": "Descriu en llenguatge planer l'àrea, el text o la marca seleccionats, amb dimensions si hi ha una escala de mesura", + "prompt": "Explica'm la selecció." + }, + "compare-sheets": { + "label": "Compara fulls", + "hint": "Indica i marca què s'ha afegit, eliminat o desplaçat respecte a una altra pàgina", + "prompt": "Compara aquesta pàgina amb la pàgina: " + }, + "measure": { + "label": "Mesura això", + "hint": "Mesura la forma seleccionada o un element descrit i indica-ho a l'escala del plànol", + "prompt": "Mesura: " + }, + "take-off": { + "label": "Treu amidaments", + "hint": "Compta o mesura elements d'un tipus i col·loca el resultat com a taula al full", + "prompt": "Treu els amidaments de: " + }, + "check-scale": { + "label": "Comprova l'escala", + "hint": "Obté l'escala del caixetí o d'una cota coneguda i defineix-la", + "prompt": "Comprova l'escala d'aquest plànol." + }, + "review-sheet": { + "label": "Revisa el full", + "hint": "Afegeix núvols amb comentaris numerats per als punts que assenyalaria un revisor", + "prompt": "Revisa aquest full pel que fa a: " + }, + "stamp-date": { + "label": "Segella i data", + "hint": "Col·loca un segell, omple un camp del caixetí o afegeix una nota de revisió amb la data d'avui", + "prompt": "Segella i data: " + }, + "clean-up": { + "label": "Endreça les marques", + "hint": "Alinea, renumera o canvia el color de les marques existents de manera coherent", + "prompt": "Endreça les meves marques: " + }, + "import-cad": { + "label": "Porta un plànol", + "hint": "Importa un plànol DWG o DXF com a fons de referència a l'escala correcta", + "prompt": "Porta el plànol (camí del fitxer DWG o DXF): " + }, + "export-cad": { + "label": "Exporta a CAD", + "hint": "Escriu aquesta pàgina o l'àrea seleccionada en un fitxer DXF o DWG", + "prompt": "Exporta aquesta pàgina a (camí del fitxer DXF o DWG): " } + }, + "groups": { + "document": "Traduir i resumir", + "reading": "Llegir el plànol", + "measuring": "Mesuraments i quantitats", + "markup": "Marcatge", + "structure": "Estructura i planta", + "cad": "Plànols CAD" + }, + "need": { + "document": "Obre primer un PDF: aquesta habilitat treballa sobre el document obert.", + "selection": "Selecciona primer una marca o una mica de text al plànol i torna a fer clic a l'habilitat.", + "scale": "Aquest plànol encara no té escala de mesura. Defineix-ne una primer (per exemple amb «Comprova l'escala») i torna-ho a provar.", + "input": "Completa el text del quadre d'entrada després dels dos punts i envia'l.", + "selectionOrInput": "Selecciona una forma al plànol, o descriu després dels dos punts què cal mesurar.", + "page": "Introdueix el número de l'altra pàgina (de l'1 al {{max}}), no el de la pàgina actual.", + "twoPages": "Aquest document només té una pàgina. Per comparar-lo amb un altre fitxer, substitueix el text del quadre d'entrada per la teva pròpia pregunta." } }, "onlineSymbolLibrary": "Biblioteca de símbols en línia", diff --git a/open-pdf-studio/js/i18n/locales/cs/common.json b/open-pdf-studio/js/i18n/locales/cs/common.json index d5ea8bed3..9d1287a9f 100644 --- a/open-pdf-studio/js/i18n/locales/cs/common.json +++ b/open-pdf-studio/js/i18n/locales/cs/common.json @@ -124,7 +124,79 @@ "label": "Půdorys", "hint": "Nakreslit stavební půdorys: stěny, výplně otvorů, místnosti, kóty a vybavení (sanita, kuchyně)", "prompt": "Nakresli stavební půdorys. Co nakreslit: " + }, + "find-on-sheet": { + "label": "Najít na listu", + "hint": "Najít výraz (název místnosti, značku řezu, symbol), přejít na jeho stránku a zvýraznit nálezy", + "prompt": "Najdi na tomto listu: " + }, + "explain-selection": { + "label": "Vysvětlit výběr", + "hint": "Srozumitelně popsat vybranou oblast, text nebo značku, s rozměry, je-li nastaveno měřítko", + "prompt": "Vysvětli výběr." + }, + "compare-sheets": { + "label": "Porovnat listy", + "hint": "Pojmenovat a označit, co bylo oproti jiné stránce přidáno, odstraněno nebo přesunuto", + "prompt": "Porovnej tuto stránku se stránkou: " + }, + "measure": { + "label": "Změřit toto", + "hint": "Změřit vybraný tvar nebo popsaný prvek a uvést výsledek v měřítku výkresu", + "prompt": "Změř: " + }, + "take-off": { + "label": "Vykázat výměry", + "hint": "Spočítat nebo změřit prvky jednoho druhu a umístit výsledek jako výkaz na list", + "prompt": "Vykaž výměry: " + }, + "check-scale": { + "label": "Zkontrolovat měřítko", + "hint": "Zjistit měřítko z rohového razítka nebo známého rozměru a nastavit ho", + "prompt": "Zkontroluj měřítko tohoto výkresu." + }, + "review-sheet": { + "label": "Revidovat list", + "hint": "Přidat obláčky s číslovanými komentáři k bodům, které by vznesl kontrolor", + "prompt": "Zkontroluj tento list z hlediska: " + }, + "stamp-date": { + "label": "Razítko a datum", + "hint": "Umístit razítko, vyplnit pole rohového razítka nebo přidat poznámku o revizi s dnešním datem", + "prompt": "Orazítkuj a datuj: " + }, + "clean-up": { + "label": "Uklidit značky", + "hint": "Jednotně zarovnat, přečíslovat nebo přebarvit stávající značky", + "prompt": "Ukliď moje značky: " + }, + "import-cad": { + "label": "Vložit výkres", + "hint": "Importovat výkres DWG nebo DXF jako podklad ve správném měřítku", + "prompt": "Vlož výkres (cesta k souboru DWG nebo DXF): " + }, + "export-cad": { + "label": "Export do CAD", + "hint": "Zapsat tuto stránku nebo vybranou oblast do souboru DXF nebo DWG", + "prompt": "Exportuj tuto stránku do (cesta k souboru DXF nebo DWG): " } + }, + "groups": { + "document": "Překlad a shrnutí", + "reading": "Čtení výkresu", + "measuring": "Měření a výměry", + "markup": "Značkování", + "structure": "Konstrukce a půdorys", + "cad": "Výkresy CAD" + }, + "need": { + "document": "Nejprve otevřete PDF: tato dovednost pracuje s otevřeným dokumentem.", + "selection": "Nejprve vyberte značku nebo část textu ve výkresu a pak znovu klikněte na dovednost.", + "scale": "Tento výkres zatím nemá měřítko pro měření. Nejprve ho nastavte (například pomocí „Zkontrolovat měřítko“) a zkuste to znovu.", + "input": "Doplňte text ve vstupním poli za dvojtečku a poté ho odešlete.", + "selectionOrInput": "Vyberte tvar ve výkresu, nebo za dvojtečkou popište, co se má změřit.", + "page": "Zadejte číslo jiné stránky (1 až {{max}}), ne aktuální stránky.", + "twoPages": "Tento dokument má jen jednu stránku. Chcete-li ho porovnat s jiným souborem, nahraďte text ve vstupním poli vlastní otázkou." } }, "onlineSymbolLibrary": "Online knihovna symbolů", diff --git a/open-pdf-studio/js/i18n/locales/da/common.json b/open-pdf-studio/js/i18n/locales/da/common.json index b0be53821..81ffacda3 100644 --- a/open-pdf-studio/js/i18n/locales/da/common.json +++ b/open-pdf-studio/js/i18n/locales/da/common.json @@ -124,7 +124,79 @@ "label": "Plantegning", "hint": "Tegn en bygningsplantegning: vægge, vinduer og døre, rum, målsætning og indretning (sanitet, køkken)", "prompt": "Tegn en bygningsplantegning. Hvad der skal tegnes: " + }, + "find-on-sheet": { + "label": "Find på arket", + "hint": "Find et begreb (et rumnavn, en snitmarkering, et symbol), gå til siden, og fremhæv resultaterne", + "prompt": "Find på dette ark: " + }, + "explain-selection": { + "label": "Forklar valget", + "hint": "Beskriv det valgte område, den valgte tekst eller markering i almindeligt sprog, med mål når en måleskala er angivet", + "prompt": "Forklar det valgte." + }, + "compare-sheets": { + "label": "Sammenlign ark", + "hint": "Navngiv og markér, hvad der er tilføjet, fjernet eller flyttet i forhold til en anden side", + "prompt": "Sammenlign denne side med side: " + }, + "measure": { + "label": "Mål dette", + "hint": "Mål den valgte figur eller et beskrevet element, og angiv det i tegningens skala", + "prompt": "Mål: " + }, + "take-off": { + "label": "Mængdeudtræk", + "hint": "Tæl eller mål elementer af én type, og placér resultatet som en liste på arket", + "prompt": "Lav mængdeudtræk af: " + }, + "check-scale": { + "label": "Kontrollér skalaen", + "hint": "Find skalaen ud fra tegningshovedet eller et kendt mål, og angiv den", + "prompt": "Kontrollér skalaen på denne tegning." + }, + "review-sheet": { + "label": "Gennemgå ark", + "hint": "Tilføj skyer med nummererede kommentarer til de punkter, en kontrollant ville påpege", + "prompt": "Gennemgå dette ark for: " + }, + "stamp-date": { + "label": "Stempl og datér", + "hint": "Placér et stempel, udfyld et felt i tegningshovedet, eller tilføj en revisionsnote med dagens dato", + "prompt": "Stempl og datér: " + }, + "clean-up": { + "label": "Ryd op i markeringer", + "hint": "Justér, omnummerér eller omfarv de eksisterende markeringer ensartet", + "prompt": "Ryd op i mine markeringer: " + }, + "import-cad": { + "label": "Hent en tegning", + "hint": "Importér en DWG- eller DXF-tegning som underlag i den rette skala", + "prompt": "Hent tegningen (sti til DWG- eller DXF-filen): " + }, + "export-cad": { + "label": "Eksportér til CAD", + "hint": "Skriv denne side eller det valgte område til en DXF- eller DWG-fil", + "prompt": "Eksportér denne side til (sti til DXF- eller DWG-filen): " } + }, + "groups": { + "document": "Oversæt og opsummér", + "reading": "Læs tegningen", + "measuring": "Måling og mængder", + "markup": "Markering", + "structure": "Konstruktion og plantegning", + "cad": "CAD-tegninger" + }, + "need": { + "document": "Åbn først en PDF: denne færdighed arbejder på det åbne dokument.", + "selection": "Vælg først en markering eller noget tekst på tegningen, og klik derefter på færdigheden igen.", + "scale": "Denne tegning har endnu ingen måleskala. Angiv først en (for eksempel med \"Kontrollér skalaen\"), og prøv igen.", + "input": "Udfyld teksten i indtastningsfeltet efter kolonet, og send den.", + "selectionOrInput": "Vælg en figur på tegningen, eller beskriv efter kolonet, hvad der skal måles.", + "page": "Angiv nummeret på den anden side (1 til {{max}}), ikke den aktuelle side.", + "twoPages": "Dette dokument har kun én side. Vil du sammenligne med en anden fil, så erstat teksten i indtastningsfeltet med dit eget spørgsmål." } }, "onlineSymbolLibrary": "Online symbolbibliotek", diff --git a/open-pdf-studio/js/i18n/locales/de/common.json b/open-pdf-studio/js/i18n/locales/de/common.json index 5b85a66fb..0e084b2c0 100644 --- a/open-pdf-studio/js/i18n/locales/de/common.json +++ b/open-pdf-studio/js/i18n/locales/de/common.json @@ -124,7 +124,79 @@ "label": "Grundriss", "hint": "Einen Bauplan-Grundriss zeichnen: Wände, Fenster und Türen, Räume, Bemaßung und Einrichtung (Sanitär, Küche)", "prompt": "Zeichne einen Grundriss. Was gezeichnet werden soll: " + }, + "find-on-sheet": { + "label": "Auf Blatt suchen", + "hint": "Einen Begriff suchen (Raumname, Schnittmarkierung, Symbol), zu seiner Seite springen und die Treffer hervorheben", + "prompt": "Suche auf diesem Blatt: " + }, + "explain-selection": { + "label": "Auswahl erklären", + "hint": "Den ausgewählten Bereich, Text oder die Markierung verständlich beschreiben, mit Abmessungen, wenn ein Messmaßstab festgelegt ist", + "prompt": "Erkläre die Auswahl." + }, + "compare-sheets": { + "label": "Blätter vergleichen", + "hint": "Benennen und markieren, was gegenüber einer anderen Seite hinzugefügt, entfernt oder verschoben wurde", + "prompt": "Vergleiche diese Seite mit Seite: " + }, + "measure": { + "label": "Dies messen", + "hint": "Die ausgewählte Form oder ein beschriebenes Element messen und im Maßstab der Zeichnung angeben", + "prompt": "Miss: " + }, + "take-off": { + "label": "Mengen ermitteln", + "hint": "Elemente einer Art zählen oder messen und das Ergebnis als Liste auf dem Blatt platzieren", + "prompt": "Ermittle die Mengen: " + }, + "check-scale": { + "label": "Maßstab prüfen", + "hint": "Den Maßstab aus dem Schriftfeld oder einer bekannten Abmessung ermitteln und festlegen", + "prompt": "Prüfe den Maßstab dieser Zeichnung." + }, + "review-sheet": { + "label": "Blatt prüfen", + "hint": "Wolken mit nummerierten Kommentaren zu den Punkten hinzufügen, die ein Prüfer anmerken würde", + "prompt": "Prüfe dieses Blatt auf: " + }, + "stamp-date": { + "label": "Stempeln und datieren", + "hint": "Einen Stempel setzen, ein Schriftfeld ausfüllen oder einen Revisionsvermerk mit dem heutigen Datum hinzufügen", + "prompt": "Stempeln und datieren: " + }, + "clean-up": { + "label": "Markierungen ordnen", + "hint": "Die vorhandenen Markierungen einheitlich ausrichten, neu nummerieren oder umfärben", + "prompt": "Räume meine Markierungen auf: " + }, + "import-cad": { + "label": "Zeichnung einfügen", + "hint": "Eine DWG- oder DXF-Zeichnung als Unterlage im richtigen Maßstab importieren", + "prompt": "Füge die Zeichnung ein (Pfad der DWG- oder DXF-Datei): " + }, + "export-cad": { + "label": "Nach CAD exportieren", + "hint": "Diese Seite oder den ausgewählten Bereich in eine DXF- oder DWG-Datei schreiben", + "prompt": "Exportiere diese Seite nach (Pfad der DXF- oder DWG-Datei): " } + }, + "groups": { + "document": "Übersetzen und zusammenfassen", + "reading": "Zeichnung lesen", + "measuring": "Messen und Mengen", + "markup": "Markieren", + "structure": "Tragwerk und Grundriss", + "cad": "CAD-Zeichnungen" + }, + "need": { + "document": "Öffnen Sie zuerst ein PDF: Diese Fähigkeit arbeitet mit dem geöffneten Dokument.", + "selection": "Wählen Sie zuerst eine Markierung oder etwas Text auf der Zeichnung aus und klicken Sie dann erneut auf die Fähigkeit.", + "scale": "Diese Zeichnung hat noch keinen Messmaßstab. Legen Sie zuerst einen fest (zum Beispiel mit „Maßstab prüfen“) und versuchen Sie es erneut.", + "input": "Ergänzen Sie den Text im Eingabefeld nach dem Doppelpunkt und senden Sie ihn dann ab.", + "selectionOrInput": "Wählen Sie eine Form auf der Zeichnung aus oder beschreiben Sie nach dem Doppelpunkt, was gemessen werden soll.", + "page": "Geben Sie die Nummer der anderen Seite ein (1 bis {{max}}), nicht die der aktuellen Seite.", + "twoPages": "Dieses Dokument hat nur eine Seite. Um mit einer anderen Datei zu vergleichen, ersetzen Sie den Text im Eingabefeld durch Ihre eigene Frage." } }, "onlineSymbolLibrary": "Online-Symbolbibliothek", diff --git a/open-pdf-studio/js/i18n/locales/el/common.json b/open-pdf-studio/js/i18n/locales/el/common.json index 9bb60e46b..5b2b407e7 100644 --- a/open-pdf-studio/js/i18n/locales/el/common.json +++ b/open-pdf-studio/js/i18n/locales/el/common.json @@ -124,7 +124,79 @@ "label": "Κάτοψη", "hint": "Σχεδίαση αρχιτεκτονικής κάτοψης: τοίχοι, κουφώματα, χώροι, διαστάσεις και εξοπλισμός (υγιεινή, κουζίνα)", "prompt": "Σχεδίασε αρχιτεκτονική κάτοψη. Τι να σχεδιαστεί: " + }, + "find-on-sheet": { + "label": "Εύρεση στο φύλλο", + "hint": "Εύρεση όρου (όνομα χώρου, σήμανση τομής, σύμβολο), μετάβαση στη σελίδα του και επισήμανση των ευρημάτων", + "prompt": "Βρες σε αυτό το φύλλο: " + }, + "explain-selection": { + "label": "Εξήγηση επιλογής", + "hint": "Περιγραφή της επιλεγμένης περιοχής, του κειμένου ή της σήμανσης σε απλή γλώσσα, με διαστάσεις όταν έχει οριστεί κλίμακα μέτρησης", + "prompt": "Εξήγησε την επιλογή." + }, + "compare-sheets": { + "label": "Σύγκριση φύλλων", + "hint": "Ονομασία και σήμανση όσων προστέθηκαν, αφαιρέθηκαν ή μετακινήθηκαν σε σχέση με άλλη σελίδα", + "prompt": "Σύγκρινε αυτή τη σελίδα με τη σελίδα: " + }, + "measure": { + "label": "Μέτρηση επιλογής", + "hint": "Μέτρηση του επιλεγμένου σχήματος ή ενός περιγραφόμενου στοιχείου και αναφορά στην κλίμακα του σχεδίου", + "prompt": "Μέτρησε: " + }, + "take-off": { + "label": "Προμέτρηση", + "hint": "Καταμέτρηση ή μέτρηση στοιχείων ενός είδους και τοποθέτηση του αποτελέσματος ως πίνακα στο φύλλο", + "prompt": "Κάνε προμέτρηση για: " + }, + "check-scale": { + "label": "Έλεγχος κλίμακας", + "hint": "Εύρεση της κλίμακας από την πινακίδα ή από γνωστή διάσταση και ορισμός της", + "prompt": "Έλεγξε την κλίμακα αυτού του σχεδίου." + }, + "review-sheet": { + "label": "Έλεγχος φύλλου", + "hint": "Προσθήκη συννεφακίων με αριθμημένα σχόλια για τα σημεία που θα επισήμαινε ένας ελεγκτής", + "prompt": "Έλεγξε αυτό το φύλλο για: " + }, + "stamp-date": { + "label": "Σφραγίδα και ημερομηνία", + "hint": "Τοποθέτηση σφραγίδας, συμπλήρωση πεδίου της πινακίδας ή προσθήκη σημείωσης αναθεώρησης με τη σημερινή ημερομηνία", + "prompt": "Βάλε σφραγίδα και ημερομηνία: " + }, + "clean-up": { + "label": "Τακτοποίηση σημάνσεων", + "hint": "Ομοιόμορφη στοίχιση, επαναρίθμηση ή αλλαγή χρώματος των υπαρχουσών σημάνσεων", + "prompt": "Τακτοποίησε τις σημάνσεις μου: " + }, + "import-cad": { + "label": "Εισαγωγή σχεδίου", + "hint": "Εισαγωγή σχεδίου DWG ή DXF ως υπόβαθρο στη σωστή κλίμακα", + "prompt": "Φέρε το σχέδιο (διαδρομή του αρχείου DWG ή DXF): " + }, + "export-cad": { + "label": "Εξαγωγή σε CAD", + "hint": "Εγγραφή αυτής της σελίδας ή της επιλεγμένης περιοχής σε αρχείο DXF ή DWG", + "prompt": "Εξήγαγε αυτή τη σελίδα σε (διαδρομή του αρχείου DXF ή DWG): " } + }, + "groups": { + "document": "Μετάφραση και σύνοψη", + "reading": "Ανάγνωση σχεδίου", + "measuring": "Μετρήσεις και ποσότητες", + "markup": "Σήμανση", + "structure": "Φέρων οργανισμός και κάτοψη", + "cad": "Σχέδια CAD" + }, + "need": { + "document": "Ανοίξτε πρώτα ένα PDF: αυτή η δεξιότητα λειτουργεί στο ανοιχτό έγγραφο.", + "selection": "Επιλέξτε πρώτα μια σήμανση ή λίγο κείμενο στο σχέδιο και, στη συνέχεια, κάντε ξανά κλικ στη δεξιότητα.", + "scale": "Αυτό το σχέδιο δεν έχει ακόμη κλίμακα μέτρησης. Ορίστε πρώτα μία (για παράδειγμα με «Έλεγχος κλίμακας») και δοκιμάστε ξανά.", + "input": "Συμπληρώστε το κείμενο στο πεδίο εισαγωγής μετά την άνω και κάτω τελεία και, στη συνέχεια, στείλτε το.", + "selectionOrInput": "Επιλέξτε ένα σχήμα στο σχέδιο ή περιγράψτε μετά την άνω και κάτω τελεία τι πρέπει να μετρηθεί.", + "page": "Εισαγάγετε τον αριθμό της άλλης σελίδας (1 έως {{max}}), όχι της τρέχουσας σελίδας.", + "twoPages": "Αυτό το έγγραφο έχει μόνο μία σελίδα. Για σύγκριση με άλλο αρχείο, αντικαταστήστε το κείμενο στο πεδίο εισαγωγής με τη δική σας ερώτηση." } }, "onlineSymbolLibrary": "Διαδικτυακή βιβλιοθήκη συμβόλων", diff --git a/open-pdf-studio/js/i18n/locales/en/common.json b/open-pdf-studio/js/i18n/locales/en/common.json index bcc123891..d46a212f2 100644 --- a/open-pdf-studio/js/i18n/locales/en/common.json +++ b/open-pdf-studio/js/i18n/locales/en/common.json @@ -125,7 +125,79 @@ "label": "Floor plan", "hint": "Draw an architectural floor plan: walls, frames, rooms, dimensions and fittings (sanitary, kitchen)", "prompt": "Draw an architectural floor plan. What to draw: " + }, + "find-on-sheet": { + "label": "Find on sheet", + "hint": "Find a term (a room name, a section marker, a symbol), go to its page and highlight the hits", + "prompt": "Find on this sheet: " + }, + "explain-selection": { + "label": "Explain selection", + "hint": "Describe the selected area, text or markup in plain language, with dimensions when a measuring scale is set", + "prompt": "Explain the selection." + }, + "compare-sheets": { + "label": "Compare sheets", + "hint": "Name and mark what was added, removed or moved compared with another page", + "prompt": "Compare this page with page: " + }, + "measure": { + "label": "Measure this", + "hint": "Measure the selected shape or a described element and report it in the drawing's scale", + "prompt": "Measure: " + }, + "take-off": { + "label": "Take off", + "hint": "Count or measure elements of one kind and place the result as a schedule on the sheet", + "prompt": "Take off: " + }, + "check-scale": { + "label": "Check the scale", + "hint": "Find the scale from the title block or a known dimension and set it", + "prompt": "Check the scale of this drawing." + }, + "review-sheet": { + "label": "Review sheet", + "hint": "Add clouds with numbered comments for the points a checker would raise", + "prompt": "Review this sheet for: " + }, + "stamp-date": { + "label": "Stamp and date", + "hint": "Place a stamp, fill a title block field or add a revision note with today's date", + "prompt": "Stamp and date: " + }, + "clean-up": { + "label": "Tidy markups", + "hint": "Align, renumber or recolour the existing markups consistently", + "prompt": "Clean up my markups: " + }, + "import-cad": { + "label": "Bring in a drawing", + "hint": "Import a DWG or DXF drawing as an underlay at the right scale", + "prompt": "Bring in the drawing (path of the DWG or DXF file): " + }, + "export-cad": { + "label": "Export to CAD", + "hint": "Write this page or the selected area to a DXF or DWG file", + "prompt": "Export this page to (path of the DXF or DWG file): " } + }, + "groups": { + "document": "Translate and summarise", + "reading": "Reading the drawing", + "measuring": "Measuring and quantities", + "markup": "Marking up", + "structure": "Structure and floor plan", + "cad": "CAD drawings" + }, + "need": { + "document": "Open a PDF first: this skill works on the open document.", + "selection": "Select a markup or some text on the drawing first, then click the skill again.", + "scale": "This drawing has no measuring scale yet. Set one first (for example with \"Check the scale\") and try again.", + "input": "Complete the text in the input box after the colon, then send.", + "selectionOrInput": "Select a shape on the drawing, or describe after the colon what to measure.", + "page": "Enter the number of the other page (1 to {{max}}), not the current page.", + "twoPages": "This document has only one page. To compare with another file, replace the text in the input box with your own question." } }, "onlineSymbolLibrary": "Online symbol library", diff --git a/open-pdf-studio/js/i18n/locales/es/common.json b/open-pdf-studio/js/i18n/locales/es/common.json index b1d4db0da..eda7f3695 100644 --- a/open-pdf-studio/js/i18n/locales/es/common.json +++ b/open-pdf-studio/js/i18n/locales/es/common.json @@ -124,7 +124,79 @@ "label": "Planta", "hint": "Dibujar una planta arquitectónica: muros, carpinterías, estancias, cotas y equipamiento (sanitarios, cocina)", "prompt": "Dibuja una planta arquitectónica. Qué hay que dibujar: " + }, + "find-on-sheet": { + "label": "Buscar en la hoja", + "hint": "Buscar un término (nombre de estancia, marca de sección, símbolo), ir a su página y resaltar los resultados", + "prompt": "Busca en esta hoja: " + }, + "explain-selection": { + "label": "Explicar selección", + "hint": "Describir con lenguaje sencillo el área, el texto o la marca seleccionados, con dimensiones si hay una escala de medición", + "prompt": "Explica la selección." + }, + "compare-sheets": { + "label": "Comparar hojas", + "hint": "Indicar y marcar lo que se ha añadido, eliminado o movido respecto a otra página", + "prompt": "Compara esta página con la página: " + }, + "measure": { + "label": "Medir esto", + "hint": "Medir la forma seleccionada o un elemento descrito e indicarlo en la escala del plano", + "prompt": "Mide: " + }, + "take-off": { + "label": "Sacar cantidades", + "hint": "Contar o medir elementos de un tipo y colocar el resultado como tabla en la hoja", + "prompt": "Saca las cantidades de: " + }, + "check-scale": { + "label": "Comprobar la escala", + "hint": "Obtener la escala del cajetín o de una cota conocida y establecerla", + "prompt": "Comprueba la escala de este plano." + }, + "review-sheet": { + "label": "Revisar hoja", + "hint": "Añadir nubes con comentarios numerados para los puntos que señalaría un revisor", + "prompt": "Revisa esta hoja en cuanto a: " + }, + "stamp-date": { + "label": "Sellar y fechar", + "hint": "Colocar un sello, rellenar un campo del cajetín o añadir una nota de revisión con la fecha de hoy", + "prompt": "Sella y fecha: " + }, + "clean-up": { + "label": "Ordenar marcas", + "hint": "Alinear, renumerar o recolorear las marcas existentes de forma coherente", + "prompt": "Ordena mis marcas: " + }, + "import-cad": { + "label": "Traer un plano", + "hint": "Importar un plano DWG o DXF como plano de fondo a la escala correcta", + "prompt": "Trae el plano (ruta del archivo DWG o DXF): " + }, + "export-cad": { + "label": "Exportar a CAD", + "hint": "Escribir esta página o el área seleccionada en un archivo DXF o DWG", + "prompt": "Exporta esta página a (ruta del archivo DXF o DWG): " } + }, + "groups": { + "document": "Traducir y resumir", + "reading": "Leer el plano", + "measuring": "Mediciones y cantidades", + "markup": "Marcado", + "structure": "Estructura y planta", + "cad": "Planos CAD" + }, + "need": { + "document": "Abre primero un PDF: esta habilidad trabaja sobre el documento abierto.", + "selection": "Selecciona primero una marca o algo de texto en el plano y vuelve a hacer clic en la habilidad.", + "scale": "Este plano aún no tiene escala de medición. Establece una primero (por ejemplo con «Comprobar la escala») e inténtalo de nuevo.", + "input": "Completa el texto del cuadro de entrada después de los dos puntos y envíalo.", + "selectionOrInput": "Selecciona una forma en el plano o describe después de los dos puntos qué hay que medir.", + "page": "Introduce el número de la otra página (de 1 a {{max}}), no el de la página actual.", + "twoPages": "Este documento solo tiene una página. Para compararlo con otro archivo, sustituye el texto del cuadro de entrada por tu propia pregunta." } }, "onlineSymbolLibrary": "Biblioteca de símbolos en línea", diff --git a/open-pdf-studio/js/i18n/locales/fa/common.json b/open-pdf-studio/js/i18n/locales/fa/common.json index d8879ecbe..f333bea73 100644 --- a/open-pdf-studio/js/i18n/locales/fa/common.json +++ b/open-pdf-studio/js/i18n/locales/fa/common.json @@ -124,7 +124,79 @@ "label": "پلان طبقه", "hint": "ترسیم پلان معماری: دیوارها، درها و پنجره‌ها، فضاها، اندازه‌گذاری و تجهیزات (بهداشتی، آشپزخانه)", "prompt": "یک پلان معماری ترسیم کن. چه چیزی باید ترسیم شود: " + }, + "find-on-sheet": { + "label": "جستجو در برگه", + "hint": "جستجوی یک واژه (نام فضا، علامت مقطع، نماد)، رفتن به صفحهٔ آن و برجسته‌کردن نتایج", + "prompt": "در این برگه جستجو کن: " + }, + "explain-selection": { + "label": "توضیح انتخاب", + "hint": "توصیف ناحیه، متن یا علامت انتخاب‌شده به زبان ساده، همراه با ابعاد در صورت تنظیم مقیاس اندازه‌گیری", + "prompt": "انتخاب را توضیح بده." + }, + "compare-sheets": { + "label": "مقایسهٔ برگه‌ها", + "hint": "نام‌بردن و علامت‌گذاری آنچه نسبت به صفحهٔ دیگر اضافه، حذف یا جابه‌جا شده است", + "prompt": "این صفحه را مقایسه کن با صفحهٔ: " + }, + "measure": { + "label": "اندازه‌گیری این مورد", + "hint": "اندازه‌گیری شکل انتخاب‌شده یا یک عنصر توصیف‌شده و گزارش آن با مقیاس نقشه", + "prompt": "اندازه‌گیری کن: " + }, + "take-off": { + "label": "برآورد مقادیر", + "hint": "شمارش یا اندازه‌گیری عناصر یک نوع و قراردادن نتیجه به‌صورت جدول روی برگه", + "prompt": "مقادیر را برآورد کن: " + }, + "check-scale": { + "label": "بررسی مقیاس", + "hint": "یافتن مقیاس از جدول مشخصات نقشه یا یک بُعد معلوم و تنظیم آن", + "prompt": "مقیاس این نقشه را بررسی کن." + }, + "review-sheet": { + "label": "بازبینی برگه", + "hint": "افزودن ابرها با توضیحات شماره‌دار برای نکاتی که یک بازبین مطرح می‌کند", + "prompt": "این برگه را از این نظر بازبینی کن: " + }, + "stamp-date": { + "label": "مهر و تاریخ", + "hint": "گذاشتن مهر، پرکردن یک فیلد جدول مشخصات یا افزودن یادداشت بازنگری با تاریخ امروز", + "prompt": "مهر و تاریخ بزن: " + }, + "clean-up": { + "label": "مرتب‌سازی علامت‌ها", + "hint": "هم‌ترازکردن، شماره‌گذاری مجدد یا تغییر رنگ یکدست علامت‌های موجود", + "prompt": "علامت‌هایم را مرتب کن: " + }, + "import-cad": { + "label": "وارد کردن نقشه", + "hint": "وارد کردن نقشهٔ DWG یا DXF به‌عنوان زمینه با مقیاس درست", + "prompt": "نقشه را وارد کن (مسیر فایل DWG یا DXF): " + }, + "export-cad": { + "label": "صدور به CAD", + "hint": "نوشتن این صفحه یا ناحیهٔ انتخاب‌شده در یک فایل DXF یا DWG", + "prompt": "این صفحه را صادر کن به (مسیر فایل DXF یا DWG): " } + }, + "groups": { + "document": "ترجمه و خلاصه", + "reading": "خواندن نقشه", + "measuring": "اندازه‌گیری و مقادیر", + "markup": "علامت‌گذاری", + "structure": "سازه و پلان", + "cad": "نقشه‌های CAD" + }, + "need": { + "document": "ابتدا یک PDF باز کنید: این مهارت روی سند باز کار می‌کند.", + "selection": "ابتدا یک علامت یا بخشی از متن روی نقشه را انتخاب کنید، سپس دوباره روی مهارت کلیک کنید.", + "scale": "این نقشه هنوز مقیاس اندازه‌گیری ندارد. ابتدا یک مقیاس تنظیم کنید (برای مثال با «بررسی مقیاس») و دوباره امتحان کنید.", + "input": "متن کادر ورودی را پس از دونقطه کامل کنید و سپس ارسال کنید.", + "selectionOrInput": "یک شکل روی نقشه انتخاب کنید، یا پس از دونقطه توضیح دهید چه چیزی باید اندازه‌گیری شود.", + "page": "شمارهٔ صفحهٔ دیگر را وارد کنید (1 تا {{max}})، نه صفحهٔ فعلی.", + "twoPages": "این سند فقط یک صفحه دارد. برای مقایسه با فایل دیگری، متن کادر ورودی را با پرسش خودتان جایگزین کنید." } }, "onlineSymbolLibrary": "کتابخانهٔ نمادهای آنلاین", diff --git a/open-pdf-studio/js/i18n/locales/fi/common.json b/open-pdf-studio/js/i18n/locales/fi/common.json index 750a1fdc9..2ca77bc98 100644 --- a/open-pdf-studio/js/i18n/locales/fi/common.json +++ b/open-pdf-studio/js/i18n/locales/fi/common.json @@ -124,7 +124,79 @@ "label": "Pohjapiirustus", "hint": "Piirrä rakennuksen pohjapiirustus: seinät, ikkunat ja ovet, tilat, mitoitus ja kalusteet (saniteetti, keittiö)", "prompt": "Piirrä rakennuksen pohjapiirustus. Mitä piirretään: " + }, + "find-on-sheet": { + "label": "Etsi arkilta", + "hint": "Etsi termi (tilan nimi, leikkausmerkki, symboli), siirry sen sivulle ja korosta osumat", + "prompt": "Etsi tältä arkilta: " + }, + "explain-selection": { + "label": "Selitä valinta", + "hint": "Kuvaile valittu alue, teksti tai merkintä selkokielellä, mittoineen, kun mittakaava on asetettu", + "prompt": "Selitä valinta." + }, + "compare-sheets": { + "label": "Vertaa arkkeja", + "hint": "Nimeä ja merkitse, mitä on lisätty, poistettu tai siirretty toiseen sivuun verrattuna", + "prompt": "Vertaa tätä sivua sivuun: " + }, + "measure": { + "label": "Mittaa tämä", + "hint": "Mittaa valittu muoto tai kuvailtu elementti ja ilmoita tulos piirustuksen mittakaavassa", + "prompt": "Mittaa: " + }, + "take-off": { + "label": "Määrälaskenta", + "hint": "Laske tai mittaa samanlaiset elementit ja sijoita tulos luettelona arkille", + "prompt": "Laske määrät: " + }, + "check-scale": { + "label": "Tarkista mittakaava", + "hint": "Selvitä mittakaava nimiöstä tai tunnetusta mitasta ja aseta se", + "prompt": "Tarkista tämän piirustuksen mittakaava." + }, + "review-sheet": { + "label": "Tarkasta arkki", + "hint": "Lisää pilviä ja numeroituja kommentteja kohtiin, joihin tarkastaja puuttuisi", + "prompt": "Tarkasta tämä arkki seuraavien osalta: " + }, + "stamp-date": { + "label": "Leima ja päiväys", + "hint": "Lisää leima, täytä nimiön kenttä tai lisää muutosmerkintä tämän päivän päiväyksellä", + "prompt": "Leimaa ja päivää: " + }, + "clean-up": { + "label": "Siisti merkinnät", + "hint": "Tasaa, numeroi uudelleen tai väritä olemassa olevat merkinnät yhtenäisesti", + "prompt": "Siisti merkintäni: " + }, + "import-cad": { + "label": "Tuo piirustus", + "hint": "Tuo DWG- tai DXF-piirustus taustakuvaksi oikeassa mittakaavassa", + "prompt": "Tuo piirustus (DWG- tai DXF-tiedoston polku): " + }, + "export-cad": { + "label": "Vie CAD-muotoon", + "hint": "Kirjoita tämä sivu tai valittu alue DXF- tai DWG-tiedostoon", + "prompt": "Vie tämä sivu tiedostoon (DXF- tai DWG-tiedoston polku): " } + }, + "groups": { + "document": "Käännä ja tiivistä", + "reading": "Piirustuksen lukeminen", + "measuring": "Mittaus ja määrät", + "markup": "Merkintöjen teko", + "structure": "Rakenne ja pohjapiirustus", + "cad": "CAD-piirustukset" + }, + "need": { + "document": "Avaa ensin PDF: tämä taito toimii avoimessa asiakirjassa.", + "selection": "Valitse ensin merkintä tai tekstiä piirustuksesta ja napsauta sitten taitoa uudelleen.", + "scale": "Tällä piirustuksella ei ole vielä mittakaavaa. Aseta se ensin (esimerkiksi taidolla \"Tarkista mittakaava\") ja yritä uudelleen.", + "input": "Täydennä syöttökentän teksti kaksoispisteen jälkeen ja lähetä se.", + "selectionOrInput": "Valitse muoto piirustuksesta tai kuvaile kaksoispisteen jälkeen, mitä mitataan.", + "page": "Anna toisen sivun numero (1–{{max}}), ei nykyisen sivun.", + "twoPages": "Tässä asiakirjassa on vain yksi sivu. Jos haluat verrata toiseen tiedostoon, korvaa syöttökentän teksti omalla kysymykselläsi." } }, "onlineSymbolLibrary": "Online-symbolikirjasto", diff --git a/open-pdf-studio/js/i18n/locales/fr/common.json b/open-pdf-studio/js/i18n/locales/fr/common.json index 45e7b2092..780edec39 100644 --- a/open-pdf-studio/js/i18n/locales/fr/common.json +++ b/open-pdf-studio/js/i18n/locales/fr/common.json @@ -124,7 +124,79 @@ "label": "Plan d'étage", "hint": "Dessiner un plan d'étage architectural : murs, menuiseries, pièces, cotations et aménagement (sanitaires, cuisine)", "prompt": "Dessine un plan d'étage architectural. Ce qu'il faut dessiner : " + }, + "find-on-sheet": { + "label": "Chercher sur la feuille", + "hint": "Rechercher un terme (nom de pièce, repère de coupe, symbole), aller à sa page et surligner les occurrences", + "prompt": "Cherche sur cette feuille : " + }, + "explain-selection": { + "label": "Expliquer la sélection", + "hint": "Décrire la zone, le texte ou l'annotation sélectionnés en langage clair, avec les dimensions si une échelle de mesure est définie", + "prompt": "Explique la sélection." + }, + "compare-sheets": { + "label": "Comparer les feuilles", + "hint": "Nommer et marquer ce qui a été ajouté, supprimé ou déplacé par rapport à une autre page", + "prompt": "Compare cette page avec la page : " + }, + "measure": { + "label": "Mesurer ceci", + "hint": "Mesurer la forme sélectionnée ou un élément décrit et donner le résultat à l'échelle du plan", + "prompt": "Mesure : " + }, + "take-off": { + "label": "Métré", + "hint": "Compter ou mesurer les éléments d'un même type et placer le résultat sous forme de tableau sur la feuille", + "prompt": "Fais le métré de : " + }, + "check-scale": { + "label": "Vérifier l'échelle", + "hint": "Déterminer l'échelle à partir du cartouche ou d'une cote connue et la définir", + "prompt": "Vérifie l'échelle de ce plan." + }, + "review-sheet": { + "label": "Contrôler la feuille", + "hint": "Ajouter des nuages avec des commentaires numérotés aux points qu'un vérificateur relèverait", + "prompt": "Contrôle cette feuille pour : " + }, + "stamp-date": { + "label": "Tampon et date", + "hint": "Placer un tampon, remplir un champ du cartouche ou ajouter une note de révision avec la date du jour", + "prompt": "Tamponne et date : " + }, + "clean-up": { + "label": "Ranger les annotations", + "hint": "Aligner, renuméroter ou recolorer les annotations existantes de façon cohérente", + "prompt": "Range mes annotations : " + }, + "import-cad": { + "label": "Importer un plan", + "hint": "Importer un plan DWG ou DXF comme fond de plan à la bonne échelle", + "prompt": "Importe le plan (chemin du fichier DWG ou DXF) : " + }, + "export-cad": { + "label": "Exporter vers CAD", + "hint": "Écrire cette page ou la zone sélectionnée dans un fichier DXF ou DWG", + "prompt": "Exporte cette page vers (chemin du fichier DXF ou DWG) : " } + }, + "groups": { + "document": "Traduire et résumer", + "reading": "Lecture du plan", + "measuring": "Métrés et quantités", + "markup": "Annotation", + "structure": "Structure et plan d'étage", + "cad": "Plans CAD" + }, + "need": { + "document": "Ouvrez d'abord un PDF : cette compétence agit sur le document ouvert.", + "selection": "Sélectionnez d'abord une annotation ou du texte sur le plan, puis cliquez à nouveau sur la compétence.", + "scale": "Ce plan n'a pas encore d'échelle de mesure. Définissez-en une d'abord (par exemple avec « Vérifier l'échelle ») et réessayez.", + "input": "Complétez le texte de la zone de saisie après les deux-points, puis envoyez.", + "selectionOrInput": "Sélectionnez une forme sur le plan, ou décrivez après les deux-points ce qu'il faut mesurer.", + "page": "Saisissez le numéro de l'autre page (1 à {{max}}), pas celui de la page actuelle.", + "twoPages": "Ce document ne comporte qu'une seule page. Pour comparer avec un autre fichier, remplacez le texte de la zone de saisie par votre propre question." } }, "onlineSymbolLibrary": "Bibliothèque de symboles en ligne", diff --git a/open-pdf-studio/js/i18n/locales/he/common.json b/open-pdf-studio/js/i18n/locales/he/common.json index 2d456e185..f12aee6d2 100644 --- a/open-pdf-studio/js/i18n/locales/he/common.json +++ b/open-pdf-studio/js/i18n/locales/he/common.json @@ -124,7 +124,79 @@ "label": "תוכנית קומה", "hint": "שרטוט תוכנית קומה אדריכלית: קירות, משקופים, חללים, מידות וריהוט (סניטריה, מטבח)", "prompt": "שרטט תוכנית קומה אדריכלית. מה לשרטט: " + }, + "find-on-sheet": { + "label": "חיפוש בגיליון", + "hint": "חיפוש מונח (שם חלל, סימון חתך, סמל), מעבר לעמוד שלו והדגשת התוצאות", + "prompt": "חפש בגיליון זה: " + }, + "explain-selection": { + "label": "הסבר הבחירה", + "hint": "תיאור האזור, הטקסט או הסימון שנבחרו בשפה פשוטה, כולל מידות כאשר מוגדר קנה מידה", + "prompt": "הסבר את הבחירה." + }, + "compare-sheets": { + "label": "השוואת גיליונות", + "hint": "ציון וסימון של מה שנוסף, הוסר או הוזז בהשוואה לעמוד אחר", + "prompt": "השווה עמוד זה לעמוד: " + }, + "measure": { + "label": "מדידת רכיב", + "hint": "מדידת הצורה שנבחרה או רכיב מתואר ודיווח התוצאה בקנה המידה של השרטוט", + "prompt": "מדוד: " + }, + "take-off": { + "label": "כתב כמויות", + "hint": "ספירה או מדידה של רכיבים מסוג אחד והצבת התוצאה כטבלה בגיליון", + "prompt": "הכן כתב כמויות עבור: " + }, + "check-scale": { + "label": "בדיקת קנה המידה", + "hint": "איתור קנה המידה מטבלת הכותרת או ממידה ידועה והגדרתו", + "prompt": "בדוק את קנה המידה של שרטוט זה." + }, + "review-sheet": { + "label": "בקרת גיליון", + "hint": "הוספת עננים עם הערות ממוספרות בנקודות שבודק היה מעיר עליהן", + "prompt": "בדוק גיליון זה לגבי: " + }, + "stamp-date": { + "label": "חותמת ותאריך", + "hint": "הצבת חותמת, מילוי שדה בטבלת הכותרת או הוספת הערת גרסה עם התאריך של היום", + "prompt": "הוסף חותמת ותאריך: " + }, + "clean-up": { + "label": "סידור סימונים", + "hint": "יישור, מספור מחדש או צביעה מחדש של הסימונים הקיימים באופן אחיד", + "prompt": "סדר את הסימונים שלי: " + }, + "import-cad": { + "label": "ייבוא שרטוט", + "hint": "ייבוא שרטוט DWG או DXF כשכבת רקע בקנה המידה הנכון", + "prompt": "ייבא את השרטוט (נתיב קובץ ה־DWG או ה־DXF): " + }, + "export-cad": { + "label": "ייצוא ל־CAD", + "hint": "כתיבת עמוד זה או האזור שנבחר לקובץ DXF או DWG", + "prompt": "ייצא עמוד זה אל (נתיב קובץ ה־DXF או ה־DWG): " } + }, + "groups": { + "document": "תרגום וסיכום", + "reading": "קריאת השרטוט", + "measuring": "מדידות וכמויות", + "markup": "סימון", + "structure": "קונסטרוקציה ותוכנית קומה", + "cad": "שרטוטי CAD" + }, + "need": { + "document": "פתחו תחילה קובץ PDF: מיומנות זו פועלת על המסמך הפתוח.", + "selection": "בחרו תחילה סימון או טקסט כלשהו בשרטוט, ואז לחצו שוב על המיומנות.", + "scale": "לשרטוט זה עדיין אין קנה מידה למדידה. הגדירו קנה מידה תחילה (למשל בעזרת \"בדיקת קנה המידה\") ונסו שוב.", + "input": "השלימו את הטקסט בתיבת הקלט אחרי הנקודתיים, ואז שלחו.", + "selectionOrInput": "בחרו צורה בשרטוט, או תארו אחרי הנקודתיים מה למדוד.", + "page": "הזינו את מספר העמוד האחר (1 עד {{max}}), לא את העמוד הנוכחי.", + "twoPages": "במסמך זה יש עמוד אחד בלבד. כדי להשוות לקובץ אחר, החליפו את הטקסט בתיבת הקלט בשאלה משלכם." } }, "onlineSymbolLibrary": "ספריית סמלים מקוונת", diff --git a/open-pdf-studio/js/i18n/locales/hi/common.json b/open-pdf-studio/js/i18n/locales/hi/common.json index f06c07c3c..547bfcb02 100644 --- a/open-pdf-studio/js/i18n/locales/hi/common.json +++ b/open-pdf-studio/js/i18n/locales/hi/common.json @@ -124,7 +124,79 @@ "label": "फ़्लोर प्लान", "hint": "वास्तु फ़्लोर प्लान बनाएँ: दीवारें, दरवाज़े-खिड़कियाँ, कमरे, आयाम और फ़िटिंग (सैनिटरी, रसोई)", "prompt": "एक वास्तु फ़्लोर प्लान बनाएँ। क्या बनाना है: " + }, + "find-on-sheet": { + "label": "शीट पर खोजें", + "hint": "कोई शब्द खोजें (कमरे का नाम, सेक्शन चिह्न, प्रतीक), उसके पृष्ठ पर जाएँ और मिले परिणामों को हाइलाइट करें", + "prompt": "इस शीट पर खोजें: " + }, + "explain-selection": { + "label": "चयन समझाएँ", + "hint": "चुने गए क्षेत्र, पाठ या मार्कअप का सरल भाषा में वर्णन करें, माप स्केल सेट होने पर आयामों सहित", + "prompt": "चयन को समझाएँ।" + }, + "compare-sheets": { + "label": "शीटों की तुलना", + "hint": "किसी दूसरे पृष्ठ की तुलना में क्या जोड़ा, हटाया या खिसकाया गया है, उसे बताएँ और चिह्नित करें", + "prompt": "इस पृष्ठ की तुलना इस पृष्ठ से करें: " + }, + "measure": { + "label": "इसे मापें", + "hint": "चुनी गई आकृति या वर्णित तत्व को मापें और परिणाम ड्रॉइंग के स्केल में बताएँ", + "prompt": "मापें: " + }, + "take-off": { + "label": "मात्रा निकालें", + "hint": "एक ही प्रकार के तत्वों को गिनें या मापें और परिणाम को शीट पर तालिका के रूप में रखें", + "prompt": "इसकी मात्रा निकालें: " + }, + "check-scale": { + "label": "स्केल जाँचें", + "hint": "टाइटल ब्लॉक या किसी ज्ञात आयाम से स्केल ज्ञात करें और उसे सेट करें", + "prompt": "इस ड्रॉइंग का स्केल जाँचें।" + }, + "review-sheet": { + "label": "शीट की समीक्षा", + "hint": "उन बिंदुओं पर क्रमांकित टिप्पणियों के साथ क्लाउड जोड़ें जिन्हें कोई जाँचकर्ता उठाएगा", + "prompt": "इस शीट की समीक्षा इसके लिए करें: " + }, + "stamp-date": { + "label": "मुहर और तारीख", + "hint": "मुहर लगाएँ, टाइटल ब्लॉक का कोई फ़ील्ड भरें या आज की तारीख के साथ संशोधन नोट जोड़ें", + "prompt": "मुहर और तारीख लगाएँ: " + }, + "clean-up": { + "label": "मार्कअप व्यवस्थित करें", + "hint": "मौजूदा मार्कअप को एकसमान रूप से संरेखित करें, फिर से क्रमांकित करें या नया रंग दें", + "prompt": "मेरे मार्कअप व्यवस्थित करें: " + }, + "import-cad": { + "label": "ड्रॉइंग लाएँ", + "hint": "DWG या DXF ड्रॉइंग को सही स्केल पर अंडरले के रूप में आयात करें", + "prompt": "ड्रॉइंग लाएँ (DWG या DXF फ़ाइल का पथ): " + }, + "export-cad": { + "label": "CAD में निर्यात", + "hint": "इस पृष्ठ या चुने गए क्षेत्र को DXF या DWG फ़ाइल में लिखें", + "prompt": "इस पृष्ठ को यहाँ निर्यात करें (DXF या DWG फ़ाइल का पथ): " } + }, + "groups": { + "document": "अनुवाद और सारांश", + "reading": "ड्रॉइंग पढ़ना", + "measuring": "माप और मात्राएँ", + "markup": "मार्कअप करना", + "structure": "संरचना और फ़्लोर प्लान", + "cad": "CAD ड्रॉइंग" + }, + "need": { + "document": "पहले एक PDF खोलें: यह कौशल खुले दस्तावेज़ पर काम करता है।", + "selection": "पहले ड्रॉइंग पर कोई मार्कअप या कुछ पाठ चुनें, फिर कौशल पर दोबारा क्लिक करें।", + "scale": "इस ड्रॉइंग में अभी कोई माप स्केल नहीं है। पहले एक स्केल सेट करें (उदाहरण के लिए \"स्केल जाँचें\" से) और फिर से प्रयास करें।", + "input": "इनपुट बॉक्स में कोलन के बाद पाठ पूरा करें, फिर भेजें।", + "selectionOrInput": "ड्रॉइंग पर कोई आकृति चुनें, या कोलन के बाद बताएँ कि क्या मापना है।", + "page": "दूसरे पृष्ठ की संख्या दर्ज करें (1 से {{max}} तक), वर्तमान पृष्ठ की नहीं।", + "twoPages": "इस दस्तावेज़ में केवल एक पृष्ठ है। किसी दूसरी फ़ाइल से तुलना करने के लिए, इनपुट बॉक्स के पाठ को अपने प्रश्न से बदलें।" } }, "onlineSymbolLibrary": "ऑनलाइन प्रतीक लाइब्रेरी", diff --git a/open-pdf-studio/js/i18n/locales/hr/common.json b/open-pdf-studio/js/i18n/locales/hr/common.json index cf8009cee..8f0bfc303 100644 --- a/open-pdf-studio/js/i18n/locales/hr/common.json +++ b/open-pdf-studio/js/i18n/locales/hr/common.json @@ -124,7 +124,79 @@ "label": "Tlocrt", "hint": "Nacrtaj arhitektonski tlocrt: zidovi, stolarija, prostorije, kote i oprema (sanitarije, kuhinja)", "prompt": "Nacrtaj arhitektonski tlocrt. Što nacrtati: " + }, + "find-on-sheet": { + "label": "Traži na listu", + "hint": "Pronađi pojam (naziv prostorije, oznaku presjeka, simbol), idi na njegovu stranicu i istakni pogotke", + "prompt": "Traži na ovom listu: " + }, + "explain-selection": { + "label": "Objasni odabir", + "hint": "Opiši odabrano područje, tekst ili oznaku jednostavnim jezikom, s dimenzijama ako je postavljeno mjerilo", + "prompt": "Objasni odabir." + }, + "compare-sheets": { + "label": "Usporedi listove", + "hint": "Navedi i označi što je dodano, uklonjeno ili pomaknuto u odnosu na drugu stranicu", + "prompt": "Usporedi ovu stranicu sa stranicom: " + }, + "measure": { + "label": "Izmjeri ovo", + "hint": "Izmjeri odabrani oblik ili opisani element i iskaži rezultat u mjerilu crteža", + "prompt": "Izmjeri: " + }, + "take-off": { + "label": "Izvod količina", + "hint": "Prebroji ili izmjeri elemente iste vrste i postavi rezultat kao tablicu na list", + "prompt": "Izradi izvod količina za: " + }, + "check-scale": { + "label": "Provjeri mjerilo", + "hint": "Odredi mjerilo iz zaglavlja ili poznate kote i postavi ga", + "prompt": "Provjeri mjerilo ovog crteža." + }, + "review-sheet": { + "label": "Pregledaj list", + "hint": "Dodaj oblake s numeriranim komentarima na mjestima koja bi kontrolor istaknuo", + "prompt": "Pregledaj ovaj list s obzirom na: " + }, + "stamp-date": { + "label": "Žig i datum", + "hint": "Postavi žig, ispuni polje zaglavlja ili dodaj bilješku o reviziji s današnjim datumom", + "prompt": "Stavi žig i datum: " + }, + "clean-up": { + "label": "Uredi oznake", + "hint": "Poravnaj, ponovno numeriraj ili ujednačeno preboji postojeće oznake", + "prompt": "Uredi moje oznake: " + }, + "import-cad": { + "label": "Uvezi crtež", + "hint": "Uvezi DWG ili DXF crtež kao podlogu u ispravnom mjerilu", + "prompt": "Uvezi crtež (putanja DWG ili DXF datoteke): " + }, + "export-cad": { + "label": "Izvezi u CAD", + "hint": "Zapiši ovu stranicu ili odabrano područje u DXF ili DWG datoteku", + "prompt": "Izvezi ovu stranicu u (putanja DXF ili DWG datoteke): " } + }, + "groups": { + "document": "Prevođenje i sažimanje", + "reading": "Čitanje crteža", + "measuring": "Mjerenje i količine", + "markup": "Označavanje", + "structure": "Konstrukcija i tlocrt", + "cad": "CAD crteži" + }, + "need": { + "document": "Najprije otvorite PDF: ova vještina radi na otvorenom dokumentu.", + "selection": "Najprije odaberite oznaku ili dio teksta na crtežu, zatim ponovno kliknite vještinu.", + "scale": "Ovaj crtež još nema mjerilo. Najprije ga postavite (na primjer vještinom „Provjeri mjerilo”) i pokušajte ponovno.", + "input": "Dovršite tekst u polju za unos nakon dvotočke, a zatim ga pošaljite.", + "selectionOrInput": "Odaberite oblik na crtežu ili nakon dvotočke opišite što treba izmjeriti.", + "page": "Unesite broj druge stranice (od 1 do {{max}}), a ne trenutne stranice.", + "twoPages": "Ovaj dokument ima samo jednu stranicu. Za usporedbu s drugom datotekom zamijenite tekst u polju za unos vlastitim pitanjem." } }, "onlineSymbolLibrary": "Mrežna knjižnica simbola", diff --git a/open-pdf-studio/js/i18n/locales/hu/common.json b/open-pdf-studio/js/i18n/locales/hu/common.json index 9b0f44530..bbd22fd1c 100644 --- a/open-pdf-studio/js/i18n/locales/hu/common.json +++ b/open-pdf-studio/js/i18n/locales/hu/common.json @@ -124,7 +124,79 @@ "label": "Alaprajz", "hint": "Építészeti alaprajz rajzolása: falak, nyílászárók, helyiségek, méretezés és berendezés (szaniter, konyha)", "prompt": "Rajzolj építészeti alaprajzot. Mit kell rajzolni: " + }, + "find-on-sheet": { + "label": "Keresés a lapon", + "hint": "Kifejezés (helyiségnév, metszetjel, szimbólum) keresése, ugrás az oldalára és a találatok kiemelése", + "prompt": "Keresd meg ezen a lapon: " + }, + "explain-selection": { + "label": "Kijelölés magyarázata", + "hint": "A kijelölt terület, szöveg vagy jelölés közérthető leírása, méretekkel, ha be van állítva mérési lépték", + "prompt": "Magyarázd el a kijelölést." + }, + "compare-sheets": { + "label": "Lapok összehasonlítása", + "hint": "Annak megnevezése és megjelölése, mi került hozzáadásra, törlésre vagy áthelyezésre egy másik oldalhoz képest", + "prompt": "Hasonlítsd össze ezt az oldalt ezzel az oldallal: " + }, + "measure": { + "label": "Elem mérése", + "hint": "A kijelölt alakzat vagy egy leírt elem megmérése, és az eredmény megadása a rajz léptékében", + "prompt": "Mérd meg: " + }, + "take-off": { + "label": "Mennyiségkimutatás", + "hint": "Egyféle elemek megszámlálása vagy megmérése, és az eredmény elhelyezése kimutatásként a lapon", + "prompt": "Készíts mennyiségkimutatást erről: " + }, + "check-scale": { + "label": "Lépték ellenőrzése", + "hint": "A lépték meghatározása a szövegmezőből vagy egy ismert méretből, és beállítása", + "prompt": "Ellenőrizd ennek a rajznak a léptékét." + }, + "review-sheet": { + "label": "Lap ellenőrzése", + "hint": "Felhők hozzáadása számozott megjegyzésekkel azokhoz a pontokhoz, amelyeket egy ellenőr kifogásolna", + "prompt": "Ellenőrizd ezt a lapot a következő szempontból: " + }, + "stamp-date": { + "label": "Bélyegző és dátum", + "hint": "Bélyegző elhelyezése, a szövegmező egy mezőjének kitöltése vagy revíziós megjegyzés hozzáadása a mai dátummal", + "prompt": "Bélyegző és dátum elhelyezése: " + }, + "clean-up": { + "label": "Jelölések rendezése", + "hint": "A meglévő jelölések egységes igazítása, újraszámozása vagy átszínezése", + "prompt": "Rendezd a jelöléseimet: " + }, + "import-cad": { + "label": "Rajz behozása", + "hint": "DWG- vagy DXF-rajz importálása alátétként a megfelelő léptékben", + "prompt": "Hozd be a rajzot (a DWG- vagy DXF-fájl elérési útja): " + }, + "export-cad": { + "label": "Exportálás CAD-be", + "hint": "Ennek az oldalnak vagy a kijelölt területnek a kiírása DXF- vagy DWG-fájlba", + "prompt": "Exportáld ezt az oldalt ide (a DXF- vagy DWG-fájl elérési útja): " } + }, + "groups": { + "document": "Fordítás és összefoglalás", + "reading": "A rajz olvasása", + "measuring": "Mérés és mennyiségek", + "markup": "Jelölés", + "structure": "Szerkezet és alaprajz", + "cad": "CAD-rajzok" + }, + "need": { + "document": "Először nyisson meg egy PDF-et: ez a képesség a megnyitott dokumentummal dolgozik.", + "selection": "Először jelöljön ki egy jelölést vagy szöveget a rajzon, majd kattintson újra a képességre.", + "scale": "Ennek a rajznak még nincs mérési léptéke. Először állítson be egyet (például a „Lépték ellenőrzése” képességgel), majd próbálja újra.", + "input": "Egészítse ki a beviteli mező szövegét a kettőspont után, majd küldje el.", + "selectionOrInput": "Jelöljön ki egy alakzatot a rajzon, vagy írja le a kettőspont után, mit kell megmérni.", + "page": "Adja meg a másik oldal számát (1 és {{max}} között), ne az aktuális oldalét.", + "twoPages": "Ez a dokumentum csak egy oldalból áll. Ha egy másik fájllal szeretné összehasonlítani, cserélje le a beviteli mező szövegét a saját kérdésére." } }, "onlineSymbolLibrary": "Online szimbólumkönyvtár", diff --git a/open-pdf-studio/js/i18n/locales/id/common.json b/open-pdf-studio/js/i18n/locales/id/common.json index c4740f36b..9b609ab0c 100644 --- a/open-pdf-studio/js/i18n/locales/id/common.json +++ b/open-pdf-studio/js/i18n/locales/id/common.json @@ -124,7 +124,79 @@ "label": "Denah", "hint": "Gambar denah arsitektur: dinding, kusen, ruang, dimensi, dan perlengkapan (sanitair, dapur)", "prompt": "Gambar denah arsitektur. Yang perlu digambar: " + }, + "find-on-sheet": { + "label": "Cari di lembar", + "hint": "Cari istilah (nama ruang, penanda potongan, simbol), buka halamannya, dan sorot hasil temuannya", + "prompt": "Cari di lembar ini: " + }, + "explain-selection": { + "label": "Jelaskan pilihan", + "hint": "Jelaskan area, teks, atau anotasi yang dipilih dengan bahasa sederhana, beserta dimensinya bila skala ukur sudah diatur", + "prompt": "Jelaskan pilihan ini." + }, + "compare-sheets": { + "label": "Bandingkan lembar", + "hint": "Sebutkan dan tandai apa yang ditambahkan, dihapus, atau dipindahkan dibandingkan halaman lain", + "prompt": "Bandingkan halaman ini dengan halaman: " + }, + "measure": { + "label": "Ukur ini", + "hint": "Ukur bentuk yang dipilih atau elemen yang dijelaskan dan laporkan dalam skala gambar", + "prompt": "Ukur: " + }, + "take-off": { + "label": "Hitung kuantitas", + "hint": "Hitung atau ukur elemen sejenis dan letakkan hasilnya sebagai daftar pada lembar", + "prompt": "Hitung kuantitas: " + }, + "check-scale": { + "label": "Periksa skala", + "hint": "Tentukan skala dari kop gambar atau dimensi yang diketahui, lalu atur", + "prompt": "Periksa skala gambar ini." + }, + "review-sheet": { + "label": "Tinjau lembar", + "hint": "Tambahkan awan dengan komentar bernomor pada poin-poin yang akan diangkat oleh pemeriksa", + "prompt": "Tinjau lembar ini terkait: " + }, + "stamp-date": { + "label": "Stempel dan tanggal", + "hint": "Bubuhkan stempel, isi kolom kop gambar, atau tambahkan catatan revisi dengan tanggal hari ini", + "prompt": "Stempel dan beri tanggal: " + }, + "clean-up": { + "label": "Rapikan anotasi", + "hint": "Ratakan, beri nomor ulang, atau ubah warna anotasi yang ada secara konsisten", + "prompt": "Rapikan anotasi saya: " + }, + "import-cad": { + "label": "Masukkan gambar", + "hint": "Impor gambar DWG atau DXF sebagai lapisan dasar pada skala yang tepat", + "prompt": "Masukkan gambar (jalur file DWG atau DXF): " + }, + "export-cad": { + "label": "Ekspor ke CAD", + "hint": "Tulis halaman ini atau area yang dipilih ke file DXF atau DWG", + "prompt": "Ekspor halaman ini ke (jalur file DXF atau DWG): " } + }, + "groups": { + "document": "Terjemahkan dan ringkas", + "reading": "Membaca gambar kerja", + "measuring": "Pengukuran dan kuantitas", + "markup": "Anotasi", + "structure": "Struktur dan denah", + "cad": "Gambar CAD" + }, + "need": { + "document": "Buka PDF terlebih dahulu: keterampilan ini bekerja pada dokumen yang terbuka.", + "selection": "Pilih anotasi atau sebagian teks pada gambar terlebih dahulu, lalu klik keterampilan itu lagi.", + "scale": "Gambar ini belum memiliki skala ukur. Atur skala terlebih dahulu (misalnya dengan \"Periksa skala\") lalu coba lagi.", + "input": "Lengkapi teks di kotak input setelah titik dua, lalu kirim.", + "selectionOrInput": "Pilih bentuk pada gambar, atau jelaskan setelah titik dua apa yang akan diukur.", + "page": "Masukkan nomor halaman lain (1 sampai {{max}}), bukan halaman saat ini.", + "twoPages": "Dokumen ini hanya memiliki satu halaman. Untuk membandingkan dengan file lain, ganti teks di kotak input dengan pertanyaan Anda sendiri." } }, "onlineSymbolLibrary": "Pustaka simbol daring", diff --git a/open-pdf-studio/js/i18n/locales/it/common.json b/open-pdf-studio/js/i18n/locales/it/common.json index 40b89618a..9ffc9d53f 100644 --- a/open-pdf-studio/js/i18n/locales/it/common.json +++ b/open-pdf-studio/js/i18n/locales/it/common.json @@ -124,7 +124,79 @@ "label": "Pianta", "hint": "Disegna una pianta architettonica: muri, serramenti, locali, quote e arredi (sanitari, cucina)", "prompt": "Disegna una pianta architettonica. Cosa disegnare: " + }, + "find-on-sheet": { + "label": "Cerca nel foglio", + "hint": "Cerca un termine (nome di un locale, indicatore di sezione, simbolo), vai alla sua pagina ed evidenzia i risultati", + "prompt": "Cerca in questo foglio: " + }, + "explain-selection": { + "label": "Spiega la selezione", + "hint": "Descrivi l'area, il testo o l'annotazione selezionati in linguaggio semplice, con le dimensioni se è impostata una scala di misura", + "prompt": "Spiega la selezione." + }, + "compare-sheets": { + "label": "Confronta fogli", + "hint": "Indica e contrassegna cosa è stato aggiunto, rimosso o spostato rispetto a un'altra pagina", + "prompt": "Confronta questa pagina con la pagina: " + }, + "measure": { + "label": "Misura questo", + "hint": "Misura la forma selezionata o un elemento descritto e riporta il risultato nella scala del disegno", + "prompt": "Misura: " + }, + "take-off": { + "label": "Computo quantità", + "hint": "Conta o misura gli elementi di un tipo e inserisci il risultato come tabella sul foglio", + "prompt": "Computa le quantità di: " + }, + "check-scale": { + "label": "Verifica la scala", + "hint": "Ricava la scala dal cartiglio o da una quota nota e impostala", + "prompt": "Verifica la scala di questo disegno." + }, + "review-sheet": { + "label": "Revisiona foglio", + "hint": "Aggiungi nuvole con commenti numerati sui punti che un verificatore solleverebbe", + "prompt": "Revisiona questo foglio per: " + }, + "stamp-date": { + "label": "Timbro e data", + "hint": "Inserisci un timbro, compila un campo del cartiglio o aggiungi una nota di revisione con la data di oggi", + "prompt": "Applica timbro e data: " + }, + "clean-up": { + "label": "Riordina annotazioni", + "hint": "Allinea, rinumera o ricolora in modo coerente le annotazioni esistenti", + "prompt": "Riordina le mie annotazioni: " + }, + "import-cad": { + "label": "Importa un disegno", + "hint": "Importa un disegno DWG o DXF come base di riferimento alla scala corretta", + "prompt": "Importa il disegno (percorso del file DWG o DXF): " + }, + "export-cad": { + "label": "Esporta in CAD", + "hint": "Scrivi questa pagina o l'area selezionata in un file DXF o DWG", + "prompt": "Esporta questa pagina in (percorso del file DXF o DWG): " } + }, + "groups": { + "document": "Traduci e riassumi", + "reading": "Lettura del disegno", + "measuring": "Misure e quantità", + "markup": "Annotazioni", + "structure": "Struttura e pianta", + "cad": "Disegni CAD" + }, + "need": { + "document": "Apri prima un PDF: questa abilità lavora sul documento aperto.", + "selection": "Seleziona prima un'annotazione o del testo sul disegno, poi fai di nuovo clic sull'abilità.", + "scale": "Questo disegno non ha ancora una scala di misura. Impostane prima una (ad esempio con «Verifica la scala») e riprova.", + "input": "Completa il testo nella casella di input dopo i due punti, poi invia.", + "selectionOrInput": "Seleziona una forma sul disegno, oppure descrivi dopo i due punti cosa misurare.", + "page": "Inserisci il numero dell'altra pagina (da 1 a {{max}}), non quello della pagina corrente.", + "twoPages": "Questo documento ha una sola pagina. Per confrontarlo con un altro file, sostituisci il testo nella casella di input con la tua domanda." } }, "onlineSymbolLibrary": "Libreria di simboli online", diff --git a/open-pdf-studio/js/i18n/locales/ja/common.json b/open-pdf-studio/js/i18n/locales/ja/common.json index 307056a93..40939a960 100644 --- a/open-pdf-studio/js/i18n/locales/ja/common.json +++ b/open-pdf-studio/js/i18n/locales/ja/common.json @@ -124,7 +124,79 @@ "label": "平面図", "hint": "建築平面図を描く:壁、建具、部屋、寸法、設備(衛生、キッチン)", "prompt": "建築平面図を描いてください。描く内容:" + }, + "find-on-sheet": { + "label": "シート内を検索", + "hint": "用語(室名、断面記号、シンボル)を検索し、そのページに移動して該当箇所をハイライト", + "prompt": "このシートで検索:" + }, + "explain-selection": { + "label": "選択内容を説明", + "hint": "選択した範囲、テキスト、注釈をわかりやすく説明(縮尺が設定されていれば寸法も)", + "prompt": "選択内容を説明してください。" + }, + "compare-sheets": { + "label": "シートを比較", + "hint": "別のページと比べて追加・削除・移動された箇所を示してマークする", + "prompt": "このページと比較するページ:" + }, + "measure": { + "label": "これを計測", + "hint": "選択した図形または記述した要素を計測し、図面の縮尺で報告", + "prompt": "計測する対象:" + }, + "take-off": { + "label": "数量拾い", + "hint": "同じ種類の要素を数えるか計測し、結果を表としてシートに配置", + "prompt": "数量を拾う対象:" + }, + "check-scale": { + "label": "縮尺を確認", + "hint": "表題欄または既知の寸法から縮尺を求めて設定", + "prompt": "この図面の縮尺を確認してください。" + }, + "review-sheet": { + "label": "シートをチェック", + "hint": "チェック担当者が指摘する箇所に番号付きコメントと雲マークを追加", + "prompt": "このシートのチェック観点:" + }, + "stamp-date": { + "label": "スタンプと日付", + "hint": "スタンプを配置、表題欄の項目を記入、または本日の日付で改訂注記を追加", + "prompt": "スタンプと日付:" + }, + "clean-up": { + "label": "マークアップを整理", + "hint": "既存のマークアップを統一的に整列、番号の振り直し、色の変更", + "prompt": "マークアップを整理:" + }, + "import-cad": { + "label": "図面を取り込む", + "hint": "DWG または DXF 図面を正しい縮尺で下図として読み込む", + "prompt": "図面を取り込む(DWG または DXF ファイルのパス):" + }, + "export-cad": { + "label": "CAD に書き出し", + "hint": "このページまたは選択範囲を DXF または DWG ファイルに書き出す", + "prompt": "このページの書き出し先(DXF または DWG ファイルのパス):" } + }, + "groups": { + "document": "翻訳と要約", + "reading": "図面の読み取り", + "measuring": "計測と数量", + "markup": "マークアップ", + "structure": "構造と平面図", + "cad": "CAD 図面" + }, + "need": { + "document": "先に PDF を開いてください。このスキルは開いている文書に対して動作します。", + "selection": "先に図面上の注釈またはテキストを選択してから、もう一度スキルをクリックしてください。", + "scale": "この図面にはまだ計測用の縮尺がありません。先に縮尺を設定してから(例:「縮尺を確認」を使用)、もう一度お試しください。", + "input": "入力欄のコロンの後に続くテキストを入力してから送信してください。", + "selectionOrInput": "図面上の図形を選択するか、コロンの後に計測する対象を記述してください。", + "page": "現在のページではなく、もう一方のページ番号(1~{{max}})を入力してください。", + "twoPages": "この文書は 1 ページしかありません。別のファイルと比較するには、入力欄のテキストをご自身の質問に置き換えてください。" } }, "onlineSymbolLibrary": "オンライン記号ライブラリ", diff --git a/open-pdf-studio/js/i18n/locales/ko/common.json b/open-pdf-studio/js/i18n/locales/ko/common.json index d7f575cee..69b8f11a8 100644 --- a/open-pdf-studio/js/i18n/locales/ko/common.json +++ b/open-pdf-studio/js/i18n/locales/ko/common.json @@ -124,7 +124,79 @@ "label": "평면도", "hint": "건축 평면도 그리기: 벽, 창호, 실, 치수, 설비(위생, 주방)", "prompt": "건축 평면도를 그려 주세요. 그릴 내용: " + }, + "find-on-sheet": { + "label": "시트에서 찾기", + "hint": "용어(실 이름, 단면 표시, 기호)를 찾아 해당 페이지로 이동하고 결과를 강조 표시", + "prompt": "이 시트에서 찾기: " + }, + "explain-selection": { + "label": "선택 항목 설명", + "hint": "선택한 영역, 텍스트 또는 마크업을 쉬운 말로 설명하고, 측정 축척이 설정되어 있으면 치수도 제공", + "prompt": "선택 항목을 설명해 주세요." + }, + "compare-sheets": { + "label": "시트 비교", + "hint": "다른 페이지와 비교하여 추가, 삭제 또는 이동된 항목을 명시하고 표시", + "prompt": "이 페이지와 비교할 페이지: " + }, + "measure": { + "label": "이것 측정", + "hint": "선택한 도형이나 설명한 요소를 측정하여 도면 축척으로 보고", + "prompt": "측정해 주세요: " + }, + "take-off": { + "label": "물량 산출", + "hint": "한 종류의 요소를 세거나 측정하고 결과를 일람표로 시트에 배치", + "prompt": "물량 산출 대상: " + }, + "check-scale": { + "label": "축척 확인", + "hint": "표제란이나 알려진 치수에서 축척을 찾아 설정", + "prompt": "이 도면의 축척을 확인해 주세요." + }, + "review-sheet": { + "label": "시트 검토", + "hint": "검토자가 지적할 부분에 번호가 매겨진 의견과 함께 구름 표시 추가", + "prompt": "이 시트의 검토 항목: " + }, + "stamp-date": { + "label": "스탬프 및 날짜", + "hint": "스탬프 배치, 표제란 항목 입력 또는 오늘 날짜로 개정 메모 추가", + "prompt": "스탬프 및 날짜: " + }, + "clean-up": { + "label": "마크업 정리", + "hint": "기존 마크업을 일관되게 정렬, 번호 재지정 또는 색상 변경", + "prompt": "내 마크업 정리: " + }, + "import-cad": { + "label": "도면 가져오기", + "hint": "DWG 또는 DXF 도면을 올바른 축척의 언더레이로 가져오기", + "prompt": "도면 가져오기(DWG 또는 DXF 파일 경로): " + }, + "export-cad": { + "label": "CAD로 내보내기", + "hint": "이 페이지나 선택 영역을 DXF 또는 DWG 파일로 쓰기", + "prompt": "이 페이지를 내보낼 위치(DXF 또는 DWG 파일 경로): " } + }, + "groups": { + "document": "번역 및 요약", + "reading": "도면 읽기", + "measuring": "측정 및 수량", + "markup": "마크업", + "structure": "구조 및 평면도", + "cad": "CAD 도면" + }, + "need": { + "document": "먼저 PDF를 여세요. 이 기능은 열려 있는 문서에서 작동합니다.", + "selection": "먼저 도면에서 마크업이나 텍스트를 선택한 다음 기능을 다시 클릭하세요.", + "scale": "이 도면에는 아직 측정 축척이 없습니다. 먼저 축척을 설정한 후(예: \"축척 확인\" 사용) 다시 시도하세요.", + "input": "입력란의 콜론 뒤에 텍스트를 완성한 다음 보내세요.", + "selectionOrInput": "도면에서 도형을 선택하거나 콜론 뒤에 측정할 대상을 설명하세요.", + "page": "현재 페이지가 아닌 다른 페이지 번호(1~{{max}})를 입력하세요.", + "twoPages": "이 문서는 페이지가 하나뿐입니다. 다른 파일과 비교하려면 입력란의 텍스트를 직접 작성한 질문으로 바꾸세요." } }, "onlineSymbolLibrary": "온라인 기호 라이브러리", diff --git a/open-pdf-studio/js/i18n/locales/lo/common.json b/open-pdf-studio/js/i18n/locales/lo/common.json index bbedcfb28..7f4cd7fb8 100644 --- a/open-pdf-studio/js/i18n/locales/lo/common.json +++ b/open-pdf-studio/js/i18n/locales/lo/common.json @@ -125,7 +125,79 @@ "label": "ແຜນຜັງພື້ນ", "hint": "ແຕ້ມແຜນຜັງພື້ນສະຖາປັດຕະຍະກຳ: ຝາ, ວົງກົບ, ຫ້ອງ, ຂະໜາດ ແລະ ອຸປະກອນ (ສຸຂະພັນ, ເຮືອນຄົວ)", "prompt": "ແຕ້ມແຜນຜັງພື້ນສະຖາປັດຕະຍະກຳ ສິ່ງທີ່ຕ້ອງແຕ້ມ: " + }, + "find-on-sheet": { + "label": "ຊອກໃນແຜ່ນ", + "hint": "ຊອກຫາຄຳ (ຊື່ຫ້ອງ, ເຄື່ອງໝາຍຮູບຕັດ, ສັນຍາລັກ), ໄປທີ່ໜ້າຂອງມັນ ແລະ ເນັ້ນສີຜົນທີ່ພົບ", + "prompt": "ຊອກໃນແຜ່ນນີ້: " + }, + "explain-selection": { + "label": "ອະທິບາຍສ່ວນທີ່ເລືອກ", + "hint": "ອະທິບາຍພື້ນທີ່, ຂໍ້ຄວາມ ຫຼື ເຄື່ອງໝາຍທີ່ເລືອກດ້ວຍພາສາງ່າຍໆ ພ້ອມຂະໜາດ ເມື່ອຕັ້ງມາດຕາສ່ວນການວັດແລ້ວ", + "prompt": "ອະທິບາຍສ່ວນທີ່ເລືອກ." + }, + "compare-sheets": { + "label": "ປຽບທຽບແຜ່ນ", + "hint": "ບອກ ແລະ ໝາຍສິ່ງທີ່ຖືກເພີ່ມ, ລຶບ ຫຼື ຍ້າຍ ເມື່ອທຽບກັບໜ້າອື່ນ", + "prompt": "ປຽບທຽບໜ້ານີ້ກັບໜ້າ: " + }, + "measure": { + "label": "ວັດອັນນີ້", + "hint": "ວັດຮູບຮ່າງທີ່ເລືອກ ຫຼື ອົງປະກອບທີ່ອະທິບາຍ ແລະ ລາຍງານຕາມມາດຕາສ່ວນຂອງແບບ", + "prompt": "ວັດ: " + }, + "take-off": { + "label": "ຖອດປະລິມານ", + "hint": "ນັບ ຫຼື ວັດອົງປະກອບປະເພດດຽວກັນ ແລະ ວາງຜົນເປັນຕາຕະລາງໃສ່ແຜ່ນ", + "prompt": "ຖອດປະລິມານ: " + }, + "check-scale": { + "label": "ກວດມາດຕາສ່ວນ", + "hint": "ຫາມາດຕາສ່ວນຈາກກອບຊື່ແບບ ຫຼື ຂະໜາດທີ່ຮູ້ ແລະ ຕັ້ງຄ່າມັນ", + "prompt": "ກວດມາດຕາສ່ວນຂອງແບບນີ້." + }, + "review-sheet": { + "label": "ກວດແຜ່ນ", + "hint": "ເພີ່ມເມກພ້ອມຄຳເຫັນທີ່ມີເລກລຳດັບ ຢູ່ຈຸດທີ່ຜູ້ກວດຈະຍົກຂຶ້ນມາ", + "prompt": "ກວດແຜ່ນນີ້ກ່ຽວກັບ: " + }, + "stamp-date": { + "label": "ປະທັບຕາ ແລະ ວັນທີ", + "hint": "ວາງຕາປະທັບ, ຕື່ມຊ່ອງໃນກອບຊື່ແບບ ຫຼື ເພີ່ມບັນທຶກການແກ້ໄຂພ້ອມວັນທີມື້ນີ້", + "prompt": "ປະທັບຕາ ແລະ ລົງວັນທີ: " + }, + "clean-up": { + "label": "ຈັດລະບຽບເຄື່ອງໝາຍ", + "hint": "ຈັດແນວ, ລຽງເລກໃໝ່ ຫຼື ປ່ຽນສີເຄື່ອງໝາຍທີ່ມີຢູ່ໃຫ້ສອດຄ່ອງກັນ", + "prompt": "ຈັດລະບຽບເຄື່ອງໝາຍຂອງຂ້ອຍ: " + }, + "import-cad": { + "label": "ນຳເຂົ້າແບບ", + "hint": "ນຳເຂົ້າແບບ DWG ຫຼື DXF ເປັນພື້ນຫຼັງຕາມມາດຕາສ່ວນທີ່ຖືກຕ້ອງ", + "prompt": "ນຳເຂົ້າແບບ (ເສັ້ນທາງຂອງໄຟລ໌ DWG ຫຼື DXF): " + }, + "export-cad": { + "label": "ສົ່ງອອກເປັນ CAD", + "hint": "ຂຽນໜ້ານີ້ ຫຼື ພື້ນທີ່ທີ່ເລືອກໄປເປັນໄຟລ໌ DXF ຫຼື DWG", + "prompt": "ສົ່ງອອກໜ້ານີ້ໄປທີ່ (ເສັ້ນທາງຂອງໄຟລ໌ DXF ຫຼື DWG): " } + }, + "groups": { + "document": "ແປ ແລະ ສະຫຼຸບ", + "reading": "ການອ່ານແບບ", + "measuring": "ການວັດ ແລະ ປະລິມານ", + "markup": "ການໝາຍ", + "structure": "ໂຄງສ້າງ ແລະ ແຜນຜັງພື້ນ", + "cad": "ແບບ CAD" + }, + "need": { + "document": "ເປີດ PDF ກ່ອນ: ທັກສະນີ້ເຮັດວຽກກັບເອກະສານທີ່ເປີດຢູ່.", + "selection": "ເລືອກເຄື່ອງໝາຍ ຫຼື ຂໍ້ຄວາມບາງສ່ວນໃນແບບກ່ອນ, ແລ້ວຄລິກທັກສະອີກຄັ້ງ.", + "scale": "ແບບນີ້ຍັງບໍ່ມີມາດຕາສ່ວນການວັດ. ຕັ້ງມາດຕາສ່ວນກ່ອນ (ຕົວຢ່າງ ດ້ວຍ \"ກວດມາດຕາສ່ວນ\") ແລ້ວລອງໃໝ່.", + "input": "ຕື່ມຂໍ້ຄວາມໃນຊ່ອງປ້ອນຂໍ້ມູນຫຼັງເຄື່ອງໝາຍຈໍ້າສອງເມັດ, ແລ້ວສົ່ງ.", + "selectionOrInput": "ເລືອກຮູບຮ່າງໃນແບບ ຫຼື ອະທິບາຍຫຼັງເຄື່ອງໝາຍຈໍ້າສອງເມັດວ່າຈະວັດຫຍັງ.", + "page": "ປ້ອນເລກໜ້າອື່ນ (1 ຫາ {{max}}), ບໍ່ແມ່ນໜ້າປັດຈຸບັນ.", + "twoPages": "ເອກະສານນີ້ມີພຽງໜ້າດຽວ. ເພື່ອປຽບທຽບກັບໄຟລ໌ອື່ນ, ໃຫ້ແທນຂໍ້ຄວາມໃນຊ່ອງປ້ອນຂໍ້ມູນດ້ວຍຄຳຖາມຂອງທ່ານເອງ." } }, "onlineSymbolLibrary": "ຄັງສັນຍະລັກອອນລາຍ", diff --git a/open-pdf-studio/js/i18n/locales/ms/common.json b/open-pdf-studio/js/i18n/locales/ms/common.json index f88bab07a..566f44a86 100644 --- a/open-pdf-studio/js/i18n/locales/ms/common.json +++ b/open-pdf-studio/js/i18n/locales/ms/common.json @@ -124,7 +124,79 @@ "label": "Pelan lantai", "hint": "Lukis pelan lantai seni bina: dinding, bingkai pintu dan tingkap, ruang, dimensi dan kelengkapan (sanitari, dapur)", "prompt": "Lukis pelan lantai seni bina. Apa yang perlu dilukis: " + }, + "find-on-sheet": { + "label": "Cari pada helaian", + "hint": "Cari istilah (nama bilik, penanda keratan, simbol), pergi ke halamannya dan serlahkan padanan", + "prompt": "Cari pada helaian ini: " + }, + "explain-selection": { + "label": "Terangkan pilihan", + "hint": "Huraikan kawasan, teks atau penanda yang dipilih dalam bahasa mudah, dengan dimensi apabila skala ukuran ditetapkan", + "prompt": "Terangkan pilihan ini." + }, + "compare-sheets": { + "label": "Banding helaian", + "hint": "Namakan dan tandakan apa yang ditambah, dibuang atau dialihkan berbanding halaman lain", + "prompt": "Bandingkan halaman ini dengan halaman: " + }, + "measure": { + "label": "Ukur ini", + "hint": "Ukur bentuk yang dipilih atau elemen yang diterangkan dan laporkan dalam skala lukisan", + "prompt": "Ukur: " + }, + "take-off": { + "label": "Ambil kuantiti", + "hint": "Kira atau ukur elemen sejenis dan letakkan hasilnya sebagai jadual pada helaian", + "prompt": "Ambil kuantiti: " + }, + "check-scale": { + "label": "Semak skala", + "hint": "Cari skala daripada blok tajuk atau dimensi yang diketahui dan tetapkannya", + "prompt": "Semak skala lukisan ini." + }, + "review-sheet": { + "label": "Semak helaian", + "hint": "Tambah awan dengan ulasan bernombor pada perkara yang akan dibangkitkan oleh penyemak", + "prompt": "Semak helaian ini untuk: " + }, + "stamp-date": { + "label": "Cop dan tarikh", + "hint": "Letakkan cop, isi medan blok tajuk atau tambah nota semakan dengan tarikh hari ini", + "prompt": "Cop dan tarikh: " + }, + "clean-up": { + "label": "Kemaskan penanda", + "hint": "Jajarkan, nomborkan semula atau warnakan semula penanda sedia ada secara seragam", + "prompt": "Kemaskan penanda saya: " + }, + "import-cad": { + "label": "Bawa masuk lukisan", + "hint": "Import lukisan DWG atau DXF sebagai lapisan dasar pada skala yang betul", + "prompt": "Bawa masuk lukisan (laluan fail DWG atau DXF): " + }, + "export-cad": { + "label": "Eksport ke CAD", + "hint": "Tulis halaman ini atau kawasan yang dipilih ke fail DXF atau DWG", + "prompt": "Eksport halaman ini ke (laluan fail DXF atau DWG): " } + }, + "groups": { + "document": "Terjemah dan ringkaskan", + "reading": "Membaca lukisan", + "measuring": "Ukuran dan kuantiti", + "markup": "Penandaan", + "structure": "Struktur dan pelan lantai", + "cad": "Lukisan CAD" + }, + "need": { + "document": "Buka PDF dahulu: kemahiran ini berfungsi pada dokumen yang dibuka.", + "selection": "Pilih penanda atau sebahagian teks pada lukisan dahulu, kemudian klik kemahiran itu sekali lagi.", + "scale": "Lukisan ini belum mempunyai skala ukuran. Tetapkan satu dahulu (contohnya dengan \"Semak skala\") dan cuba lagi.", + "input": "Lengkapkan teks dalam kotak input selepas titik bertindih, kemudian hantar.", + "selectionOrInput": "Pilih bentuk pada lukisan, atau terangkan selepas titik bertindih apa yang hendak diukur.", + "page": "Masukkan nombor halaman yang lain (1 hingga {{max}}), bukan halaman semasa.", + "twoPages": "Dokumen ini hanya mempunyai satu halaman. Untuk membandingkan dengan fail lain, gantikan teks dalam kotak input dengan soalan anda sendiri." } }, "onlineSymbolLibrary": "Pustaka simbol dalam talian", diff --git a/open-pdf-studio/js/i18n/locales/nb/common.json b/open-pdf-studio/js/i18n/locales/nb/common.json index 9810c3a03..26a54f9f8 100644 --- a/open-pdf-studio/js/i18n/locales/nb/common.json +++ b/open-pdf-studio/js/i18n/locales/nb/common.json @@ -124,7 +124,79 @@ "label": "Plantegning", "hint": "Tegn en bygningsplantegning: vegger, vinduer og dører, rom, målsetting og innredning (sanitær, kjøkken)", "prompt": "Tegn en bygningsplantegning. Hva som skal tegnes: " + }, + "find-on-sheet": { + "label": "Finn på arket", + "hint": "Finn et begrep (et romnavn, en snittmarkering, et symbol), gå til siden og uthev treffene", + "prompt": "Finn på dette arket: " + }, + "explain-selection": { + "label": "Forklar utvalget", + "hint": "Beskriv det valgte området, teksten eller markeringen med enkle ord, med mål når en målestokk er angitt", + "prompt": "Forklar utvalget." + }, + "compare-sheets": { + "label": "Sammenlign ark", + "hint": "Navngi og marker det som er lagt til, fjernet eller flyttet sammenlignet med en annen side", + "prompt": "Sammenlign denne siden med side: " + }, + "measure": { + "label": "Mål dette", + "hint": "Mål den valgte figuren eller et beskrevet element og oppgi resultatet i tegningens målestokk", + "prompt": "Mål: " + }, + "take-off": { + "label": "Mengdeuttak", + "hint": "Tell eller mål elementer av én type og plasser resultatet som en liste på arket", + "prompt": "Ta ut mengder for: " + }, + "check-scale": { + "label": "Kontroller målestokken", + "hint": "Finn målestokken fra tittelfeltet eller et kjent mål og angi den", + "prompt": "Kontroller målestokken på denne tegningen." + }, + "review-sheet": { + "label": "Kontroller arket", + "hint": "Legg til skyer med nummererte kommentarer ved punktene en kontrollør ville tatt opp", + "prompt": "Kontroller dette arket for: " + }, + "stamp-date": { + "label": "Stempel og dato", + "hint": "Plasser et stempel, fyll ut et felt i tittelfeltet eller legg til en revisjonsmerknad med dagens dato", + "prompt": "Stempel og dato: " + }, + "clean-up": { + "label": "Rydd i markeringer", + "hint": "Juster, nummerer om eller fargelegg de eksisterende markeringene ensartet", + "prompt": "Rydd opp i markeringene mine: " + }, + "import-cad": { + "label": "Hent inn tegning", + "hint": "Importer en DWG- eller DXF-tegning som underlag i riktig målestokk", + "prompt": "Hent inn tegningen (bane til DWG- eller DXF-filen): " + }, + "export-cad": { + "label": "Eksporter til CAD", + "hint": "Skriv denne siden eller det valgte området til en DXF- eller DWG-fil", + "prompt": "Eksporter denne siden til (bane til DXF- eller DWG-filen): " } + }, + "groups": { + "document": "Oversett og oppsummer", + "reading": "Lese tegningen", + "measuring": "Måling og mengder", + "markup": "Markering", + "structure": "Konstruksjon og plantegning", + "cad": "CAD-tegninger" + }, + "need": { + "document": "Åpne en PDF først: denne ferdigheten virker på det åpne dokumentet.", + "selection": "Velg en markering eller litt tekst på tegningen først, og klikk deretter på ferdigheten igjen.", + "scale": "Denne tegningen har ingen målestokk ennå. Angi en først (for eksempel med «Kontroller målestokken») og prøv igjen.", + "input": "Fullfør teksten i inndatafeltet etter kolonet, og send deretter.", + "selectionOrInput": "Velg en figur på tegningen, eller beskriv etter kolonet hva som skal måles.", + "page": "Angi nummeret på den andre siden (1 til {{max}}), ikke den gjeldende siden.", + "twoPages": "Dette dokumentet har bare én side. For å sammenligne med en annen fil, erstatt teksten i inndatafeltet med ditt eget spørsmål." } }, "onlineSymbolLibrary": "Nettbasert symbolbibliotek", diff --git a/open-pdf-studio/js/i18n/locales/nl/common.json b/open-pdf-studio/js/i18n/locales/nl/common.json index 527fb61f7..9e7131ed7 100644 --- a/open-pdf-studio/js/i18n/locales/nl/common.json +++ b/open-pdf-studio/js/i18n/locales/nl/common.json @@ -122,7 +122,79 @@ "label": "Plattegrond", "hint": "Teken een bouwkundige plattegrond: wanden, kozijnen, ruimten, maatvoering en inrichting (sanitair, keuken)", "prompt": "Teken een bouwkundige plattegrond. Wat er getekend moet worden: " + }, + "find-on-sheet": { + "label": "Zoek op blad", + "hint": "Zoek een term (een ruimtenaam, een doorsnedemarkering, een symbool), ga naar de pagina en markeer de treffers", + "prompt": "Zoek op dit blad: " + }, + "explain-selection": { + "label": "Leg selectie uit", + "hint": "Beschrijf het geselecteerde gebied, de tekst of de markering in gewone taal, met maten als er een meetschaal is", + "prompt": "Leg de selectie uit." + }, + "compare-sheets": { + "label": "Vergelijk bladen", + "hint": "Benoem en markeer wat er is toegevoegd, verwijderd of verplaatst ten opzichte van een andere pagina", + "prompt": "Vergelijk deze pagina met pagina: " + }, + "measure": { + "label": "Meet dit", + "hint": "Meet de geselecteerde vorm of een beschreven onderdeel en geef het resultaat in de schaal van de tekening", + "prompt": "Meet: " + }, + "take-off": { + "label": "Hoeveelheden", + "hint": "Tel of meet onderdelen van één soort en zet het resultaat als staat op het blad", + "prompt": "Bepaal de hoeveelheden van: " + }, + "check-scale": { + "label": "Controleer de schaal", + "hint": "Bepaal de schaal uit de titelhoek of een bekende maat en stel hem in", + "prompt": "Controleer de schaal van deze tekening." + }, + "review-sheet": { + "label": "Controleer blad", + "hint": "Zet wolken met genummerde opmerkingen bij de punten die een controleur zou aankaarten", + "prompt": "Controleer dit blad op: " + }, + "stamp-date": { + "label": "Stempel en datum", + "hint": "Plaats een stempel, vul een veld van de titelhoek of zet een revisienotitie met de datum van vandaag", + "prompt": "Stempel en datum: " + }, + "clean-up": { + "label": "Markeringen opschonen", + "hint": "Lijn de bestaande markeringen uit, nummer ze opnieuw of geef ze een gelijke kleur", + "prompt": "Schoon mijn markeringen op: " + }, + "import-cad": { + "label": "Tekening inlezen", + "hint": "Lees een DWG- of DXF-tekening in als onderlegger op de juiste schaal", + "prompt": "Lees de tekening in (pad van het DWG- of DXF-bestand): " + }, + "export-cad": { + "label": "Exporteer naar CAD", + "hint": "Schrijf deze pagina of het geselecteerde gebied naar een DXF- of DWG-bestand", + "prompt": "Exporteer deze pagina naar (pad van het DXF- of DWG-bestand): " } + }, + "groups": { + "document": "Vertalen en samenvatten", + "reading": "Tekening lezen", + "measuring": "Meten en hoeveelheden", + "markup": "Markeren", + "structure": "Constructie en plattegrond", + "cad": "CAD-tekeningen" + }, + "need": { + "document": "Open eerst een PDF: deze vaardigheid werkt op het geopende document.", + "selection": "Selecteer eerst een markering of wat tekst op de tekening en klik dan opnieuw op de vaardigheid.", + "scale": "Deze tekening heeft nog geen meetschaal. Stel er eerst een in (bijvoorbeeld met \"Controleer de schaal\") en probeer het opnieuw.", + "input": "Vul de tekst in het invoervak aan na de dubbele punt en verstuur dan.", + "selectionOrInput": "Selecteer een vorm op de tekening, of beschrijf na de dubbele punt wat er gemeten moet worden.", + "page": "Geef het nummer van de andere pagina (1 tot en met {{max}}), niet de huidige pagina.", + "twoPages": "Dit document heeft maar één pagina. Wil je met een ander bestand vergelijken, vervang dan de tekst in het invoervak door je eigen vraag." } }, "onlineSymbolLibrary": "Online symboolbibliotheek", diff --git a/open-pdf-studio/js/i18n/locales/pl/common.json b/open-pdf-studio/js/i18n/locales/pl/common.json index 80d338095..36d26016e 100644 --- a/open-pdf-studio/js/i18n/locales/pl/common.json +++ b/open-pdf-studio/js/i18n/locales/pl/common.json @@ -124,7 +124,79 @@ "label": "Rzut", "hint": "Narysuj rzut architektoniczny: ściany, stolarka, pomieszczenia, wymiary i wyposażenie (sanitariaty, kuchnia)", "prompt": "Narysuj rzut architektoniczny. Co narysować: " + }, + "find-on-sheet": { + "label": "Znajdź na arkuszu", + "hint": "Znajdź termin (nazwę pomieszczenia, oznaczenie przekroju, symbol), przejdź do jego strony i podświetl wyniki", + "prompt": "Znajdź na tym arkuszu: " + }, + "explain-selection": { + "label": "Objaśnij zaznaczenie", + "hint": "Opisz zaznaczony obszar, tekst lub adnotację prostym językiem, z wymiarami, gdy ustawiono skalę pomiarową", + "prompt": "Objaśnij zaznaczenie." + }, + "compare-sheets": { + "label": "Porównaj arkusze", + "hint": "Nazwij i oznacz, co dodano, usunięto lub przesunięto w porównaniu z inną stroną", + "prompt": "Porównaj tę stronę ze stroną: " + }, + "measure": { + "label": "Zmierz to", + "hint": "Zmierz zaznaczony kształt lub opisany element i podaj wynik w skali rysunku", + "prompt": "Zmierz: " + }, + "take-off": { + "label": "Przedmiar", + "hint": "Policz lub zmierz elementy jednego rodzaju i umieść wynik jako zestawienie na arkuszu", + "prompt": "Wykonaj przedmiar: " + }, + "check-scale": { + "label": "Sprawdź skalę", + "hint": "Ustal skalę z tabelki rysunkowej lub znanego wymiaru i ją ustaw", + "prompt": "Sprawdź skalę tego rysunku." + }, + "review-sheet": { + "label": "Sprawdź arkusz", + "hint": "Dodaj chmurki z numerowanymi uwagami w miejscach, które wskazałby sprawdzający", + "prompt": "Sprawdź ten arkusz pod kątem: " + }, + "stamp-date": { + "label": "Pieczątka i data", + "hint": "Umieść pieczątkę, wypełnij pole tabelki rysunkowej lub dodaj notatkę o rewizji z dzisiejszą datą", + "prompt": "Pieczątka i data: " + }, + "clean-up": { + "label": "Uporządkuj adnotacje", + "hint": "Wyrównaj, ponumeruj od nowa lub ujednolić kolory istniejących adnotacji", + "prompt": "Uporządkuj moje adnotacje: " + }, + "import-cad": { + "label": "Wczytaj rysunek", + "hint": "Zaimportuj rysunek DWG lub DXF jako podkład we właściwej skali", + "prompt": "Wczytaj rysunek (ścieżka pliku DWG lub DXF): " + }, + "export-cad": { + "label": "Eksportuj do CAD", + "hint": "Zapisz tę stronę lub zaznaczony obszar do pliku DXF lub DWG", + "prompt": "Eksportuj tę stronę do (ścieżka pliku DXF lub DWG): " } + }, + "groups": { + "document": "Tłumaczenie i streszczenie", + "reading": "Czytanie rysunku", + "measuring": "Pomiary i ilości", + "markup": "Oznaczanie", + "structure": "Konstrukcja i rzut", + "cad": "Rysunki CAD" + }, + "need": { + "document": "Najpierw otwórz PDF: ta umiejętność działa na otwartym dokumencie.", + "selection": "Najpierw zaznacz adnotację lub fragment tekstu na rysunku, a potem ponownie kliknij umiejętność.", + "scale": "Ten rysunek nie ma jeszcze skali pomiarowej. Najpierw ją ustaw (na przykład za pomocą „Sprawdź skalę”) i spróbuj ponownie.", + "input": "Uzupełnij tekst w polu wprowadzania po dwukropku, a potem wyślij.", + "selectionOrInput": "Zaznacz kształt na rysunku lub opisz po dwukropku, co zmierzyć.", + "page": "Podaj numer innej strony (od 1 do {{max}}), nie bieżącej.", + "twoPages": "Ten dokument ma tylko jedną stronę. Aby porównać go z innym plikiem, zastąp tekst w polu wprowadzania własnym pytaniem." } }, "onlineSymbolLibrary": "Biblioteka symboli online", diff --git a/open-pdf-studio/js/i18n/locales/pt/common.json b/open-pdf-studio/js/i18n/locales/pt/common.json index a2705f6e1..1af1543a3 100644 --- a/open-pdf-studio/js/i18n/locales/pt/common.json +++ b/open-pdf-studio/js/i18n/locales/pt/common.json @@ -124,7 +124,79 @@ "label": "Planta baixa", "hint": "Desenhar uma planta arquitetônica: paredes, esquadrias, ambientes, cotas e equipamentos (sanitários, cozinha)", "prompt": "Desenhe uma planta arquitetônica. O que desenhar: " + }, + "find-on-sheet": { + "label": "Localizar na folha", + "hint": "Localizar um termo (nome de ambiente, marcador de corte, símbolo), ir até a página dele e destacar os resultados", + "prompt": "Localize nesta folha: " + }, + "explain-selection": { + "label": "Explicar seleção", + "hint": "Descrever a área, o texto ou a marcação selecionada em linguagem simples, com dimensões quando houver uma escala de medição definida", + "prompt": "Explique a seleção." + }, + "compare-sheets": { + "label": "Comparar folhas", + "hint": "Identificar e marcar o que foi adicionado, removido ou movido em relação a outra página", + "prompt": "Compare esta página com a página: " + }, + "measure": { + "label": "Medir isto", + "hint": "Medir a forma selecionada ou um elemento descrito e informar o resultado na escala do desenho", + "prompt": "Meça: " + }, + "take-off": { + "label": "Levantar quantitativos", + "hint": "Contar ou medir elementos de um mesmo tipo e colocar o resultado como quadro na folha", + "prompt": "Levante os quantitativos de: " + }, + "check-scale": { + "label": "Verificar a escala", + "hint": "Encontrar a escala no selo ou numa cota conhecida e defini-la", + "prompt": "Verifique a escala deste desenho." + }, + "review-sheet": { + "label": "Revisar folha", + "hint": "Adicionar nuvens com comentários numerados nos pontos que um verificador apontaria", + "prompt": "Revise esta folha quanto a: " + }, + "stamp-date": { + "label": "Carimbar e datar", + "hint": "Colocar um carimbo, preencher um campo do selo ou adicionar uma nota de revisão com a data de hoje", + "prompt": "Carimbe e date: " + }, + "clean-up": { + "label": "Organizar marcações", + "hint": "Alinhar, renumerar ou recolorir as marcações existentes de forma consistente", + "prompt": "Organize minhas marcações: " + }, + "import-cad": { + "label": "Importar desenho", + "hint": "Importar um desenho DWG ou DXF como base na escala correta", + "prompt": "Importe o desenho (caminho do arquivo DWG ou DXF): " + }, + "export-cad": { + "label": "Exportar para CAD", + "hint": "Gravar esta página ou a área selecionada num arquivo DXF ou DWG", + "prompt": "Exporte esta página para (caminho do arquivo DXF ou DWG): " } + }, + "groups": { + "document": "Traduzir e resumir", + "reading": "Leitura do desenho", + "measuring": "Medições e quantitativos", + "markup": "Marcações", + "structure": "Estrutura e planta baixa", + "cad": "Desenhos CAD" + }, + "need": { + "document": "Abra um PDF primeiro: esta habilidade funciona no documento aberto.", + "selection": "Selecione primeiro uma marcação ou um trecho de texto no desenho e clique na habilidade novamente.", + "scale": "Este desenho ainda não tem escala de medição. Defina uma primeiro (por exemplo, com “Verificar a escala”) e tente novamente.", + "input": "Complete o texto na caixa de entrada após os dois-pontos e envie.", + "selectionOrInput": "Selecione uma forma no desenho ou descreva após os dois-pontos o que medir.", + "page": "Informe o número da outra página (1 a {{max}}), não da página atual.", + "twoPages": "Este documento tem apenas uma página. Para comparar com outro arquivo, substitua o texto na caixa de entrada pela sua própria pergunta." } }, "onlineSymbolLibrary": "Biblioteca de símbolos online", diff --git a/open-pdf-studio/js/i18n/locales/ro/common.json b/open-pdf-studio/js/i18n/locales/ro/common.json index de30896fc..0c51186e2 100644 --- a/open-pdf-studio/js/i18n/locales/ro/common.json +++ b/open-pdf-studio/js/i18n/locales/ro/common.json @@ -124,7 +124,79 @@ "label": "Plan de nivel", "hint": "Desenează un plan arhitectural: pereți, tâmplărie, încăperi, cote și dotări (sanitare, bucătărie)", "prompt": "Desenează un plan arhitectural. Ce trebuie desenat: " + }, + "find-on-sheet": { + "label": "Caută pe planșă", + "hint": "Caută un termen (un nume de încăpere, un marcaj de secțiune, un simbol), mergi la pagina lui și evidențiază rezultatele", + "prompt": "Caută pe această planșă: " + }, + "explain-selection": { + "label": "Explică selecția", + "hint": "Descrie în limbaj simplu zona, textul sau marcajul selectat, cu dimensiuni dacă este setată o scară de măsurare", + "prompt": "Explică selecția." + }, + "compare-sheets": { + "label": "Compară planșe", + "hint": "Numește și marchează ce a fost adăugat, eliminat sau mutat față de altă pagină", + "prompt": "Compară această pagină cu pagina: " + }, + "measure": { + "label": "Măsoară asta", + "hint": "Măsoară forma selectată sau un element descris și raportează rezultatul la scara planșei", + "prompt": "Măsoară: " + }, + "take-off": { + "label": "Antemăsurătoare", + "hint": "Numără sau măsoară elementele de un singur tip și plasează rezultatul ca tabel pe planșă", + "prompt": "Fă antemăsurătoarea pentru: " + }, + "check-scale": { + "label": "Verifică scara", + "hint": "Determină scara din cartuș sau dintr-o cotă cunoscută și seteaz-o", + "prompt": "Verifică scara acestei planșe." + }, + "review-sheet": { + "label": "Verifică planșa", + "hint": "Adaugă nori cu comentarii numerotate la punctele pe care le-ar semnala un verificator", + "prompt": "Verifică această planșă pentru: " + }, + "stamp-date": { + "label": "Ștampilă și dată", + "hint": "Plasează o ștampilă, completează un câmp din cartuș sau adaugă o notă de revizie cu data de azi", + "prompt": "Ștampilă și dată: " + }, + "clean-up": { + "label": "Ordonează marcajele", + "hint": "Aliniază, renumerotează sau recolorează uniform marcajele existente", + "prompt": "Ordonează-mi marcajele: " + }, + "import-cad": { + "label": "Importă un desen", + "hint": "Importă un desen DWG sau DXF ca fundal la scara corectă", + "prompt": "Importă desenul (calea fișierului DWG sau DXF): " + }, + "export-cad": { + "label": "Exportă în CAD", + "hint": "Scrie această pagină sau zona selectată într-un fișier DXF sau DWG", + "prompt": "Exportă această pagină în (calea fișierului DXF sau DWG): " } + }, + "groups": { + "document": "Traducere și rezumat", + "reading": "Citirea planșei", + "measuring": "Măsurători și cantități", + "markup": "Marcaje", + "structure": "Structură și plan de nivel", + "cad": "Desene CAD" + }, + "need": { + "document": "Deschideți mai întâi un PDF: această abilitate lucrează pe documentul deschis.", + "selection": "Selectați mai întâi un marcaj sau un text pe planșă, apoi faceți din nou clic pe abilitate.", + "scale": "Această planșă nu are încă o scară de măsurare. Setați mai întâi una (de exemplu cu „Verifică scara”) și încercați din nou.", + "input": "Completați textul din caseta de introducere după cele două puncte, apoi trimiteți.", + "selectionOrInput": "Selectați o formă pe planșă sau descrieți după cele două puncte ce trebuie măsurat.", + "page": "Introduceți numărul celeilalte pagini (de la 1 la {{max}}), nu al paginii curente.", + "twoPages": "Acest document are o singură pagină. Pentru a compara cu alt fișier, înlocuiți textul din caseta de introducere cu propria întrebare." } }, "onlineSymbolLibrary": "Bibliotecă de simboluri online", diff --git a/open-pdf-studio/js/i18n/locales/ru/common.json b/open-pdf-studio/js/i18n/locales/ru/common.json index d919e935f..027cd5544 100644 --- a/open-pdf-studio/js/i18n/locales/ru/common.json +++ b/open-pdf-studio/js/i18n/locales/ru/common.json @@ -124,7 +124,79 @@ "label": "План этажа", "hint": "Начертить архитектурный план этажа: стены, проёмы, помещения, размеры и оборудование (сантехника, кухня)", "prompt": "Начерти архитектурный план этажа. Что нужно начертить: " + }, + "find-on-sheet": { + "label": "Найти на листе", + "hint": "Найти термин (название помещения, маркер разреза, символ), перейти на его страницу и подсветить совпадения", + "prompt": "Найди на этом листе: " + }, + "explain-selection": { + "label": "Пояснить выделение", + "hint": "Описать выделенную область, текст или пометку простым языком, с размерами, если задан масштаб измерений", + "prompt": "Поясни выделенное." + }, + "compare-sheets": { + "label": "Сравнить листы", + "hint": "Назвать и отметить, что добавлено, удалено или перемещено по сравнению с другой страницей", + "prompt": "Сравни эту страницу со страницей: " + }, + "measure": { + "label": "Измерить это", + "hint": "Измерить выделенную фигуру или описанный элемент и указать результат в масштабе чертежа", + "prompt": "Измерь: " + }, + "take-off": { + "label": "Подсчёт объёмов", + "hint": "Посчитать или измерить элементы одного типа и разместить результат на листе в виде ведомости", + "prompt": "Подсчитай объёмы: " + }, + "check-scale": { + "label": "Проверить масштаб", + "hint": "Определить масштаб по основной надписи или известному размеру и задать его", + "prompt": "Проверь масштаб этого чертежа." + }, + "review-sheet": { + "label": "Проверить лист", + "hint": "Добавить облака с пронумерованными замечаниями там, где их сделал бы проверяющий", + "prompt": "Проверь этот лист на: " + }, + "stamp-date": { + "label": "Штамп и дата", + "hint": "Поставить штамп, заполнить поле основной надписи или добавить отметку об изменении с сегодняшней датой", + "prompt": "Поставь штамп и дату: " + }, + "clean-up": { + "label": "Упорядочить пометки", + "hint": "Единообразно выровнять, перенумеровать или перекрасить существующие пометки", + "prompt": "Упорядочи мои пометки: " + }, + "import-cad": { + "label": "Вставить чертёж", + "hint": "Импортировать чертёж DWG или DXF как подложку в нужном масштабе", + "prompt": "Вставь чертёж (путь к файлу DWG или DXF): " + }, + "export-cad": { + "label": "Экспорт в CAD", + "hint": "Записать эту страницу или выделенную область в файл DXF или DWG", + "prompt": "Экспортируй эту страницу в (путь к файлу DXF или DWG): " } + }, + "groups": { + "document": "Перевод и резюме", + "reading": "Чтение чертежа", + "measuring": "Измерения и объёмы", + "markup": "Пометки", + "structure": "Конструкции и планировка", + "cad": "Чертежи CAD" + }, + "need": { + "document": "Сначала откройте PDF: этот навык работает с открытым документом.", + "selection": "Сначала выделите пометку или фрагмент текста на чертеже, затем снова нажмите на навык.", + "scale": "У этого чертежа ещё нет масштаба измерений. Сначала задайте его (например, с помощью «Проверить масштаб») и повторите попытку.", + "input": "Допишите текст в поле ввода после двоеточия и отправьте.", + "selectionOrInput": "Выделите фигуру на чертеже или опишите после двоеточия, что нужно измерить.", + "page": "Введите номер другой страницы (от 1 до {{max}}), а не текущей.", + "twoPages": "В этом документе только одна страница. Чтобы сравнить его с другим файлом, замените текст в поле ввода своим вопросом." } }, "onlineSymbolLibrary": "Онлайн-библиотека символов", diff --git a/open-pdf-studio/js/i18n/locales/sk/common.json b/open-pdf-studio/js/i18n/locales/sk/common.json index b451464b3..5f78ae5f2 100644 --- a/open-pdf-studio/js/i18n/locales/sk/common.json +++ b/open-pdf-studio/js/i18n/locales/sk/common.json @@ -124,7 +124,79 @@ "label": "Pôdorys", "hint": "Nakresliť stavebný pôdorys: steny, výplne otvorov, miestnosti, kóty a vybavenie (sanita, kuchyňa)", "prompt": "Nakresli stavebný pôdorys. Čo nakresliť: " + }, + "find-on-sheet": { + "label": "Nájsť na liste", + "hint": "Nájsť výraz (názov miestnosti, značku rezu, symbol), prejsť na jeho stranu a zvýrazniť nálezy", + "prompt": "Nájdi na tomto liste: " + }, + "explain-selection": { + "label": "Vysvetliť výber", + "hint": "Opísať vybranú oblasť, text alebo značku jednoduchým jazykom, s rozmermi, ak je nastavená mierka merania", + "prompt": "Vysvetli výber." + }, + "compare-sheets": { + "label": "Porovnať listy", + "hint": "Pomenovať a označiť, čo pribudlo, bolo odstránené alebo presunuté v porovnaní s inou stranou", + "prompt": "Porovnaj túto stranu so stranou: " + }, + "measure": { + "label": "Zmerať toto", + "hint": "Zmerať vybraný tvar alebo opísaný prvok a uviesť výsledok v mierke výkresu", + "prompt": "Zmeraj: " + }, + "take-off": { + "label": "Výkaz výmer", + "hint": "Spočítať alebo zmerať prvky jedného druhu a umiestniť výsledok ako výkaz na list", + "prompt": "Urob výkaz výmer pre: " + }, + "check-scale": { + "label": "Skontrolovať mierku", + "hint": "Zistiť mierku z popisového poľa alebo zo známeho rozmeru a nastaviť ju", + "prompt": "Skontroluj mierku tohto výkresu." + }, + "review-sheet": { + "label": "Skontrolovať list", + "hint": "Pridať obláčiky s číslovanými komentármi pri bodoch, ktoré by namietol kontrolór", + "prompt": "Skontroluj tento list z hľadiska: " + }, + "stamp-date": { + "label": "Pečiatka a dátum", + "hint": "Umiestniť pečiatku, vyplniť položku popisového poľa alebo pridať poznámku o revízii s dnešným dátumom", + "prompt": "Pečiatka a dátum: " + }, + "clean-up": { + "label": "Upratať značky", + "hint": "Jednotne zarovnať, prečíslovať alebo prefarbiť existujúce značky", + "prompt": "Uprac moje značky: " + }, + "import-cad": { + "label": "Vložiť výkres", + "hint": "Importovať výkres DWG alebo DXF ako podklad v správnej mierke", + "prompt": "Vlož výkres (cesta k súboru DWG alebo DXF): " + }, + "export-cad": { + "label": "Exportovať do CAD", + "hint": "Zapísať túto stranu alebo vybranú oblasť do súboru DXF alebo DWG", + "prompt": "Exportuj túto stranu do (cesta k súboru DXF alebo DWG): " } + }, + "groups": { + "document": "Preklad a zhrnutie", + "reading": "Čítanie výkresu", + "measuring": "Meranie a výmery", + "markup": "Označovanie", + "structure": "Konštrukcia a pôdorys", + "cad": "Výkresy CAD" + }, + "need": { + "document": "Najprv otvorte PDF: táto zručnosť pracuje s otvoreným dokumentom.", + "selection": "Najprv vyberte značku alebo časť textu na výkrese a potom znova kliknite na zručnosť.", + "scale": "Tento výkres zatiaľ nemá mierku merania. Najprv ju nastavte (napríklad pomocou „Skontrolovať mierku“) a skúste to znova.", + "input": "Doplňte text vo vstupnom poli za dvojbodkou a potom ho odošlite.", + "selectionOrInput": "Vyberte tvar na výkrese alebo za dvojbodkou opíšte, čo treba zmerať.", + "page": "Zadajte číslo inej strany (1 až {{max}}), nie aktuálnej strany.", + "twoPages": "Tento dokument má iba jednu stranu. Ak ho chcete porovnať s iným súborom, nahraďte text vo vstupnom poli vlastnou otázkou." } }, "onlineSymbolLibrary": "Online knižnica symbolov", diff --git a/open-pdf-studio/js/i18n/locales/sr/common.json b/open-pdf-studio/js/i18n/locales/sr/common.json index e9914779b..4206d4d34 100644 --- a/open-pdf-studio/js/i18n/locales/sr/common.json +++ b/open-pdf-studio/js/i18n/locales/sr/common.json @@ -124,7 +124,79 @@ "label": "Основа", "hint": "Нацртај архитектонску основу: зидови, столарија, просторије, коте и опрема (санитарије, кухиња)", "prompt": "Нацртај архитектонску основу. Шта нацртати: " + }, + "find-on-sheet": { + "label": "Пронађи на листу", + "hint": "Пронађи појам (назив просторије, ознаку пресека, симбол), иди на његову страницу и истакни погодке", + "prompt": "Пронађи на овом листу: " + }, + "explain-selection": { + "label": "Објасни избор", + "hint": "Опиши изабрану област, текст или ознаку једноставним језиком, са мерама ако је подешена размера", + "prompt": "Објасни избор." + }, + "compare-sheets": { + "label": "Упореди листове", + "hint": "Наведи и означи шта је додато, уклоњено или померено у односу на другу страницу", + "prompt": "Упореди ову страницу са страницом: " + }, + "measure": { + "label": "Измери ово", + "hint": "Измери изабрани облик или описани елемент и прикажи резултат у размери цртежа", + "prompt": "Измери: " + }, + "take-off": { + "label": "Предмер", + "hint": "Преброј или измери елементе једне врсте и постави резултат као спецификацију на лист", + "prompt": "Направи предмер за: " + }, + "check-scale": { + "label": "Провери размеру", + "hint": "Одреди размеру из заглавља или познате мере и подеси је", + "prompt": "Провери размеру овог цртежа." + }, + "review-sheet": { + "label": "Прегледај лист", + "hint": "Додај облачиће са нумерисаним коментарима код тачака које би контролор истакао", + "prompt": "Прегледај овај лист у погледу: " + }, + "stamp-date": { + "label": "Печат и датум", + "hint": "Постави печат, попуни поље заглавља или додај напомену о ревизији са данашњим датумом", + "prompt": "Печат и датум: " + }, + "clean-up": { + "label": "Среди ознаке", + "hint": "Уједначено поравнај, пренумериши или обој постојеће ознаке", + "prompt": "Среди моје ознаке: " + }, + "import-cad": { + "label": "Убаци цртеж", + "hint": "Увези DWG или DXF цртеж као подлогу у одговарајућој размери", + "prompt": "Убаци цртеж (путања до DWG или DXF датотеке): " + }, + "export-cad": { + "label": "Извези у CAD", + "hint": "Упиши ову страницу или изабрану област у DXF или DWG датотеку", + "prompt": "Извези ову страницу у (путања до DXF или DWG датотеке): " } + }, + "groups": { + "document": "Превод и сажетак", + "reading": "Читање цртежа", + "measuring": "Мерење и количине", + "markup": "Означавање", + "structure": "Конструкција и основа", + "cad": "CAD цртежи" + }, + "need": { + "document": "Прво отворите PDF: ова вештина ради на отвореном документу.", + "selection": "Прво изаберите ознаку или део текста на цртежу, а затим поново кликните на вештину.", + "scale": "Овај цртеж још нема размеру за мерење. Прво је подесите (на пример помоћу „Провери размеру”) и покушајте поново.", + "input": "Допуните текст у пољу за унос после двотачке, а затим пошаљите.", + "selectionOrInput": "Изаберите облик на цртежу или после двотачке опишите шта треба измерити.", + "page": "Унесите број друге странице (од 1 до {{max}}), а не тренутне.", + "twoPages": "Овај документ има само једну страницу. Да бисте га упоредили са другом датотеком, замените текст у пољу за унос сопственим питањем." } }, "onlineSymbolLibrary": "Онлајн библиотека симбола", diff --git a/open-pdf-studio/js/i18n/locales/sv/common.json b/open-pdf-studio/js/i18n/locales/sv/common.json index 923ca1509..1258256dc 100644 --- a/open-pdf-studio/js/i18n/locales/sv/common.json +++ b/open-pdf-studio/js/i18n/locales/sv/common.json @@ -124,7 +124,79 @@ "label": "Planritning", "hint": "Rita en byggnadsplanritning: väggar, fönster och dörrar, rum, måttsättning och inredning (sanitet, kök)", "prompt": "Rita en byggnadsplanritning. Vad som ska ritas: " + }, + "find-on-sheet": { + "label": "Sök på bladet", + "hint": "Sök efter en term (ett rumsnamn, en sektionsmarkering, en symbol), gå till dess sida och markera träffarna", + "prompt": "Sök på det här bladet: " + }, + "explain-selection": { + "label": "Förklara urvalet", + "hint": "Beskriv det valda området, texten eller markeringen på enkel svenska, med mått när en mätskala är inställd", + "prompt": "Förklara urvalet." + }, + "compare-sheets": { + "label": "Jämför blad", + "hint": "Namnge och markera vad som lagts till, tagits bort eller flyttats jämfört med en annan sida", + "prompt": "Jämför den här sidan med sida: " + }, + "measure": { + "label": "Mät detta", + "hint": "Mät den valda formen eller ett beskrivet element och redovisa det i ritningens skala", + "prompt": "Mät: " + }, + "take-off": { + "label": "Ta fram mängder", + "hint": "Räkna eller mät element av ett slag och placera resultatet som en förteckning på bladet", + "prompt": "Ta fram mängder: " + }, + "check-scale": { + "label": "Kontrollera skalan", + "hint": "Hitta skalan i namnrutan eller utifrån ett känt mått och ställ in den", + "prompt": "Kontrollera skalan på den här ritningen." + }, + "review-sheet": { + "label": "Granska bladet", + "hint": "Lägg till moln med numrerade kommentarer för de punkter en granskare skulle ta upp", + "prompt": "Granska det här bladet med avseende på: " + }, + "stamp-date": { + "label": "Stämpla och datera", + "hint": "Placera en stämpel, fyll i ett fält i namnrutan eller lägg till en revideringsanteckning med dagens datum", + "prompt": "Stämpla och datera: " + }, + "clean-up": { + "label": "Städa markeringar", + "hint": "Justera, numrera om eller färga om befintliga markeringar på ett enhetligt sätt", + "prompt": "Städa upp mina markeringar: " + }, + "import-cad": { + "label": "Hämta in en ritning", + "hint": "Importera en DWG- eller DXF-ritning som underlägg i rätt skala", + "prompt": "Hämta in ritningen (sökväg till DWG- eller DXF-filen): " + }, + "export-cad": { + "label": "Exportera till CAD", + "hint": "Skriv den här sidan eller det valda området till en DXF- eller DWG-fil", + "prompt": "Exportera den här sidan till (sökväg till DXF- eller DWG-filen): " } + }, + "groups": { + "document": "Översätt och sammanfatta", + "reading": "Läsa ritningen", + "measuring": "Mätning och mängder", + "markup": "Markeringar", + "structure": "Stomme och planritning", + "cad": "CAD-ritningar" + }, + "need": { + "document": "Öppna en PDF först: den här färdigheten arbetar med det öppna dokumentet.", + "selection": "Välj först en markering eller lite text på ritningen och klicka sedan på färdigheten igen.", + "scale": "Den här ritningen har ännu ingen mätskala. Ställ in en först (till exempel med ”Kontrollera skalan”) och försök igen.", + "input": "Fyll i texten i inmatningsrutan efter kolonet och skicka sedan.", + "selectionOrInput": "Välj en form på ritningen, eller beskriv efter kolonet vad som ska mätas.", + "page": "Ange numret på den andra sidan (1 till {{max}}), inte den aktuella sidan.", + "twoPages": "Det här dokumentet har bara en sida. Om du vill jämföra med en annan fil ersätter du texten i inmatningsrutan med din egen fråga." } }, "onlineSymbolLibrary": "Symbolbibliotek online", diff --git a/open-pdf-studio/js/i18n/locales/sw/common.json b/open-pdf-studio/js/i18n/locales/sw/common.json index 3e45adfaa..afa2efafe 100644 --- a/open-pdf-studio/js/i18n/locales/sw/common.json +++ b/open-pdf-studio/js/i18n/locales/sw/common.json @@ -124,7 +124,79 @@ "label": "Ramani ya sakafu", "hint": "Chora ramani ya sakafu ya jengo: kuta, fremu za milango na madirisha, vyumba, vipimo na vifaa (vyoo, jiko)", "prompt": "Chora ramani ya sakafu ya jengo. Kinachopaswa kuchorwa: " + }, + "find-on-sheet": { + "label": "Tafuta kwenye karatasi", + "hint": "Tafuta neno (jina la chumba, alama ya sehemu, alama), nenda kwenye ukurasa wake na uangazie matokeo", + "prompt": "Tafuta kwenye karatasi hii: " + }, + "explain-selection": { + "label": "Eleza uteuzi", + "hint": "Eleza eneo, maandishi au alama iliyochaguliwa kwa lugha rahisi, pamoja na vipimo ikiwa kipimo cha upimaji kimewekwa", + "prompt": "Eleza uteuzi." + }, + "compare-sheets": { + "label": "Linganisha karatasi", + "hint": "Taja na uweke alama kilichoongezwa, kuondolewa au kuhamishwa ukilinganisha na ukurasa mwingine", + "prompt": "Linganisha ukurasa huu na ukurasa: " + }, + "measure": { + "label": "Pima hiki", + "hint": "Pima umbo lililochaguliwa au kipengele kilichoelezwa na uripoti kwa kipimo cha mchoro", + "prompt": "Pima: " + }, + "take-off": { + "label": "Hesabu idadi", + "hint": "Hesabu au pima vipengele vya aina moja na uweke matokeo kama jedwali kwenye karatasi", + "prompt": "Hesabu idadi ya: " + }, + "check-scale": { + "label": "Kagua kipimo", + "hint": "Tafuta kipimo kutoka kwenye kizuizi cha kichwa au kipimo kinachojulikana na ukiweke", + "prompt": "Kagua kipimo cha mchoro huu." + }, + "review-sheet": { + "label": "Kagua karatasi", + "hint": "Ongeza mawingu yenye maoni yaliyopewa namba kwa hoja ambazo mkaguzi angeibua", + "prompt": "Kagua karatasi hii kwa: " + }, + "stamp-date": { + "label": "Muhuri na tarehe", + "hint": "Weka muhuri, jaza sehemu ya kizuizi cha kichwa au ongeza maelezo ya marekebisho yenye tarehe ya leo", + "prompt": "Weka muhuri na tarehe: " + }, + "clean-up": { + "label": "Panga alama", + "hint": "Panga, weka namba upya au badilisha rangi za alama zilizopo kwa uthabiti", + "prompt": "Panga alama zangu: " + }, + "import-cad": { + "label": "Leta mchoro", + "hint": "Leta mchoro wa DWG au DXF kama msingi kwa kipimo sahihi", + "prompt": "Leta mchoro (njia ya faili ya DWG au DXF): " + }, + "export-cad": { + "label": "Hamisha kwenda CAD", + "hint": "Andika ukurasa huu au eneo lililochaguliwa kwenye faili ya DXF au DWG", + "prompt": "Hamisha ukurasa huu kwenda (njia ya faili ya DXF au DWG): " } + }, + "groups": { + "document": "Tafsiri na fupisha", + "reading": "Kusoma mchoro", + "measuring": "Vipimo na idadi", + "markup": "Kuweka alama", + "structure": "Muundo na ramani ya sakafu", + "cad": "Michoro ya CAD" + }, + "need": { + "document": "Fungua PDF kwanza: ujuzi huu unafanya kazi kwenye hati iliyofunguliwa.", + "selection": "Chagua alama au maandishi kwenye mchoro kwanza, kisha bofya ujuzi tena.", + "scale": "Mchoro huu bado hauna kipimo cha upimaji. Weka kimoja kwanza (kwa mfano kwa \"Kagua kipimo\") kisha ujaribu tena.", + "input": "Kamilisha maandishi kwenye kisanduku cha kuingiza baada ya nukta mbili, kisha utume.", + "selectionOrInput": "Chagua umbo kwenye mchoro, au eleza baada ya nukta mbili kinachopaswa kupimwa.", + "page": "Weka namba ya ukurasa mwingine (1 hadi {{max}}), si ukurasa wa sasa.", + "twoPages": "Hati hii ina ukurasa mmoja tu. Ili kulinganisha na faili nyingine, badilisha maandishi kwenye kisanduku cha kuingiza kwa swali lako mwenyewe." } }, "onlineSymbolLibrary": "Maktaba ya alama mtandaoni", diff --git a/open-pdf-studio/js/i18n/locales/ta/common.json b/open-pdf-studio/js/i18n/locales/ta/common.json index 3f29d326f..4860de046 100644 --- a/open-pdf-studio/js/i18n/locales/ta/common.json +++ b/open-pdf-studio/js/i18n/locales/ta/common.json @@ -124,7 +124,79 @@ "label": "தளத் திட்டம்", "hint": "கட்டிடக்கலை தளத் திட்டத்தை வரை: சுவர்கள், கதவு-ஜன்னல் சட்டங்கள், அறைகள், அளவுகள் மற்றும் பொருத்துதல்கள் (சுகாதாரம், சமையலறை)", "prompt": "ஒரு கட்டிடக்கலை தளத் திட்டத்தை வரை. என்ன வரைய வேண்டும்: " + }, + "find-on-sheet": { + "label": "தாளில் தேடு", + "hint": "ஒரு சொல்லை (அறையின் பெயர், வெட்டுமுகக் குறி, குறியீடு) தேடி, அதன் பக்கத்துக்குச் சென்று கண்டவற்றை முன்னிலைப்படுத்து", + "prompt": "இந்தத் தாளில் தேடு: " + }, + "explain-selection": { + "label": "தேர்வை விளக்கு", + "hint": "தேர்ந்தெடுத்த பகுதி, உரை அல்லது குறிப்பை எளிய மொழியில் விவரி; அளவீட்டு அளவுகோல் அமைக்கப்பட்டிருந்தால் பரிமாணங்களுடன்", + "prompt": "தேர்வை விளக்கு." + }, + "compare-sheets": { + "label": "தாள்களை ஒப்பிடு", + "hint": "மற்றொரு பக்கத்துடன் ஒப்பிடுகையில் சேர்க்கப்பட்டவை, நீக்கப்பட்டவை அல்லது நகர்த்தப்பட்டவற்றைக் குறிப்பிட்டுக் குறி", + "prompt": "இந்தப் பக்கத்தை ஒப்பிட வேண்டிய பக்கம்: " + }, + "measure": { + "label": "இதை அள", + "hint": "தேர்ந்தெடுத்த வடிவத்தையோ விவரிக்கப்பட்ட உறுப்பையோ அளந்து வரைபடத்தின் அளவுகோலில் தெரிவி", + "prompt": "அள: " + }, + "take-off": { + "label": "அளவுப் பட்டியல்", + "hint": "ஒரே வகை உறுப்புகளை எண்ணி அல்லது அளந்து, முடிவைத் தாளில் அட்டவணையாக வை", + "prompt": "அளவுப் பட்டியல் தயாரி: " + }, + "check-scale": { + "label": "அளவுகோலைச் சரிபார்", + "hint": "தலைப்புத் தொகுதியிலிருந்து அல்லது தெரிந்த பரிமாணத்திலிருந்து அளவுகோலைக் கண்டறிந்து அமை", + "prompt": "இந்த வரைபடத்தின் அளவுகோலைச் சரிபார்." + }, + "review-sheet": { + "label": "தாளை மதிப்பாய்வு செய்", + "hint": "சரிபார்ப்பவர் எழுப்பும் புள்ளிகளுக்கு எண்ணிடப்பட்ட கருத்துகளுடன் மேகக் குறிகளைச் சேர்", + "prompt": "இந்தத் தாளில் மதிப்பாய்வு செய்ய வேண்டியது: " + }, + "stamp-date": { + "label": "முத்திரையும் தேதியும்", + "hint": "ஒரு முத்திரையை வை, தலைப்புத் தொகுதிப் புலத்தை நிரப்பு அல்லது இன்றைய தேதியுடன் திருத்தக் குறிப்பைச் சேர்", + "prompt": "முத்திரையிட்டுத் தேதியிடு: " + }, + "clean-up": { + "label": "குறிப்புகளை ஒழுங்குபடுத்து", + "hint": "இருக்கும் குறிப்புகளைச் சீராக வரிசைப்படுத்து, மறு எண்ணிடு அல்லது மறு வண்ணமிடு", + "prompt": "என் குறிப்புகளை ஒழுங்குபடுத்து: " + }, + "import-cad": { + "label": "வரைபடத்தைக் கொண்டுவா", + "hint": "DWG அல்லது DXF வரைபடத்தைச் சரியான அளவுகோலில் அடிப்படை அடுக்காக இறக்குமதி செய்", + "prompt": "வரைபடத்தைக் கொண்டுவா (DWG அல்லது DXF கோப்பின் பாதை): " + }, + "export-cad": { + "label": "CAD-க்கு ஏற்றுமதி", + "hint": "இந்தப் பக்கத்தையோ தேர்ந்தெடுத்த பகுதியையோ DXF அல்லது DWG கோப்பில் எழுது", + "prompt": "இந்தப் பக்கத்தை ஏற்றுமதி செய்ய (DXF அல்லது DWG கோப்பின் பாதை): " } + }, + "groups": { + "document": "மொழிபெயர்ப்பும் சுருக்கமும்", + "reading": "வரைபடத்தைப் படித்தல்", + "measuring": "அளவீடும் அளவுகளும்", + "markup": "குறியிடுதல்", + "structure": "கட்டமைப்பும் தளத் திட்டமும்", + "cad": "CAD வரைபடங்கள்" + }, + "need": { + "document": "முதலில் ஒரு PDF-ஐத் திறக்கவும்: இந்தத் திறன் திறந்த ஆவணத்தில் செயல்படும்.", + "selection": "முதலில் வரைபடத்தில் ஒரு குறிப்பையோ சிறிது உரையையோ தேர்ந்தெடுத்து, பின்னர் திறனை மீண்டும் கிளிக் செய்யவும்.", + "scale": "இந்த வரைபடத்துக்கு இன்னும் அளவீட்டு அளவுகோல் இல்லை. முதலில் ஒன்றை அமைத்து (எடுத்துக்காட்டாக \"அளவுகோலைச் சரிபார்\" மூலம்) மீண்டும் முயலவும்.", + "input": "உள்ளீட்டுப் பெட்டியில் முக்காற்புள்ளிக்குப் பிறகு உரையை நிறைவு செய்து, பின்னர் அனுப்பவும்.", + "selectionOrInput": "வரைபடத்தில் ஒரு வடிவத்தைத் தேர்ந்தெடுக்கவும், அல்லது எதை அளக்க வேண்டும் என்பதை முக்காற்புள்ளிக்குப் பிறகு விவரிக்கவும்.", + "page": "தற்போதைய பக்கத்தை அல்ல, மற்றப் பக்கத்தின் எண்ணை உள்ளிடவும் (1 முதல் {{max}} வரை).", + "twoPages": "இந்த ஆவணத்தில் ஒரே ஒரு பக்கம் மட்டுமே உள்ளது. வேறொரு கோப்புடன் ஒப்பிட, உள்ளீட்டுப் பெட்டியில் உள்ள உரையை உங்கள் சொந்தக் கேள்வியால் மாற்றவும்." } }, "onlineSymbolLibrary": "இணையக் குறியீட்டு நூலகம்", diff --git a/open-pdf-studio/js/i18n/locales/th/common.json b/open-pdf-studio/js/i18n/locales/th/common.json index d1f222a29..26d7629ea 100644 --- a/open-pdf-studio/js/i18n/locales/th/common.json +++ b/open-pdf-studio/js/i18n/locales/th/common.json @@ -124,7 +124,79 @@ "label": "แปลนพื้น", "hint": "วาดแปลนพื้นสถาปัตยกรรม: ผนัง วงกบ ห้อง ขนาด และอุปกรณ์ (สุขภัณฑ์ ครัว)", "prompt": "วาดแปลนพื้นสถาปัตยกรรม สิ่งที่ต้องวาด: " + }, + "find-on-sheet": { + "label": "ค้นหาในแผ่น", + "hint": "ค้นหาคำ (ชื่อห้อง เครื่องหมายรูปตัด สัญลักษณ์) ไปยังหน้าที่พบและไฮไลต์ผลลัพธ์", + "prompt": "ค้นหาในแผ่นนี้: " + }, + "explain-selection": { + "label": "อธิบายส่วนที่เลือก", + "hint": "อธิบายพื้นที่ ข้อความ หรือเครื่องหมายที่เลือกด้วยภาษาที่เข้าใจง่าย พร้อมขนาดเมื่อตั้งมาตราส่วนการวัดไว้", + "prompt": "อธิบายส่วนที่เลือก" + }, + "compare-sheets": { + "label": "เปรียบเทียบแผ่น", + "hint": "ระบุและทำเครื่องหมายสิ่งที่เพิ่ม ลบ หรือย้ายเมื่อเทียบกับหน้าอื่น", + "prompt": "เปรียบเทียบหน้านี้กับหน้า: " + }, + "measure": { + "label": "วัดส่วนนี้", + "hint": "วัดรูปทรงที่เลือกหรือองค์ประกอบที่อธิบายไว้ และรายงานตามมาตราส่วนของแบบ", + "prompt": "วัด: " + }, + "take-off": { + "label": "ถอดปริมาณ", + "hint": "นับหรือวัดองค์ประกอบประเภทเดียวกันและวางผลลัพธ์เป็นตารางบนแผ่น", + "prompt": "ถอดปริมาณ: " + }, + "check-scale": { + "label": "ตรวจสอบมาตราส่วน", + "hint": "หามาตราส่วนจากกรอบชื่อแบบหรือขนาดที่ทราบแล้วตั้งค่า", + "prompt": "ตรวจสอบมาตราส่วนของแบบนี้" + }, + "review-sheet": { + "label": "ตรวจทานแผ่น", + "hint": "เพิ่มเมฆพร้อมความคิดเห็นที่มีหมายเลขสำหรับประเด็นที่ผู้ตรวจแบบจะยกขึ้นมา", + "prompt": "ตรวจทานแผ่นนี้ในเรื่อง: " + }, + "stamp-date": { + "label": "ประทับตราและลงวันที่", + "hint": "วางตราประทับ กรอกช่องในกรอบชื่อแบบ หรือเพิ่มบันทึกการแก้ไขพร้อมวันที่วันนี้", + "prompt": "ประทับตราและลงวันที่: " + }, + "clean-up": { + "label": "จัดระเบียบเครื่องหมาย", + "hint": "จัดแนว เรียงหมายเลขใหม่ หรือเปลี่ยนสีเครื่องหมายที่มีอยู่ให้สม่ำเสมอ", + "prompt": "จัดระเบียบเครื่องหมายของฉัน: " + }, + "import-cad": { + "label": "นำเข้าแบบ", + "hint": "นำเข้าแบบ DWG หรือ DXF เป็นชั้นอ้างอิงด้านล่างในมาตราส่วนที่ถูกต้อง", + "prompt": "นำเข้าแบบ (พาธของไฟล์ DWG หรือ DXF): " + }, + "export-cad": { + "label": "ส่งออกเป็น CAD", + "hint": "เขียนหน้านี้หรือพื้นที่ที่เลือกลงในไฟล์ DXF หรือ DWG", + "prompt": "ส่งออกหน้านี้ไปยัง (พาธของไฟล์ DXF หรือ DWG): " } + }, + "groups": { + "document": "แปลและสรุป", + "reading": "อ่านแบบ", + "measuring": "การวัดและปริมาณ", + "markup": "การทำเครื่องหมาย", + "structure": "โครงสร้างและแปลนพื้น", + "cad": "แบบ CAD" + }, + "need": { + "document": "เปิด PDF ก่อน: ทักษะนี้ทำงานกับเอกสารที่เปิดอยู่", + "selection": "เลือกเครื่องหมายหรือข้อความบนแบบก่อน แล้วคลิกทักษะอีกครั้ง", + "scale": "แบบนี้ยังไม่มีมาตราส่วนการวัด ตั้งค่ามาตราส่วนก่อน (เช่น ด้วย \"ตรวจสอบมาตราส่วน\") แล้วลองอีกครั้ง", + "input": "กรอกข้อความในช่องป้อนข้อมูลต่อจากเครื่องหมายทวิภาค แล้วส่ง", + "selectionOrInput": "เลือกรูปทรงบนแบบ หรืออธิบายสิ่งที่ต้องการวัดต่อจากเครื่องหมายทวิภาค", + "page": "ป้อนหมายเลขของหน้าอื่น (1 ถึง {{max}}) ไม่ใช่หน้าปัจจุบัน", + "twoPages": "เอกสารนี้มีเพียงหน้าเดียว หากต้องการเปรียบเทียบกับไฟล์อื่น ให้แทนที่ข้อความในช่องป้อนข้อมูลด้วยคำถามของคุณเอง" } }, "onlineSymbolLibrary": "คลังสัญลักษณ์ออนไลน์", diff --git a/open-pdf-studio/js/i18n/locales/tr/common.json b/open-pdf-studio/js/i18n/locales/tr/common.json index 2a82e0fcd..5cbfbed69 100644 --- a/open-pdf-studio/js/i18n/locales/tr/common.json +++ b/open-pdf-studio/js/i18n/locales/tr/common.json @@ -124,7 +124,79 @@ "label": "Kat planı", "hint": "Mimari kat planı çiz: duvarlar, doğramalar, mahaller, ölçülendirme ve donatılar (ıslak hacim, mutfak)", "prompt": "Mimari bir kat planı çiz. Çizilecek olan: " + }, + "find-on-sheet": { + "label": "Paftada bul", + "hint": "Bir terimi (oda adı, kesit işareti, sembol) bul, sayfasına git ve bulunanları vurgula", + "prompt": "Bu paftada bul: " + }, + "explain-selection": { + "label": "Seçimi açıkla", + "hint": "Seçili alanı, metni veya işaretlemeyi sade bir dille açıkla; ölçüm ölçeği ayarlıysa ölçüleriyle birlikte", + "prompt": "Seçimi açıkla." + }, + "compare-sheets": { + "label": "Paftaları karşılaştır", + "hint": "Başka bir sayfaya göre eklenen, kaldırılan veya taşınan öğeleri adlandır ve işaretle", + "prompt": "Bu sayfayı şu sayfayla karşılaştır: " + }, + "measure": { + "label": "Bunu ölç", + "hint": "Seçili şekli veya tarif edilen bir öğeyi ölç ve çizimin ölçeğinde bildir", + "prompt": "Ölç: " + }, + "take-off": { + "label": "Metraj çıkar", + "hint": "Tek türdeki öğeleri say veya ölç ve sonucu paftaya bir çizelge olarak yerleştir", + "prompt": "Metraj çıkar: " + }, + "check-scale": { + "label": "Ölçeği kontrol et", + "hint": "Ölçeği antetten veya bilinen bir ölçüden bul ve ayarla", + "prompt": "Bu çizimin ölçeğini kontrol et." + }, + "review-sheet": { + "label": "Paftayı denetle", + "hint": "Bir kontrolörün dile getireceği noktalar için numaralı yorumlarla bulutlar ekle", + "prompt": "Bu paftayı şu açıdan denetle: " + }, + "stamp-date": { + "label": "Damgala ve tarih at", + "hint": "Bir damga yerleştir, bir antet alanını doldur veya bugünün tarihiyle bir revizyon notu ekle", + "prompt": "Damgala ve tarih at: " + }, + "clean-up": { + "label": "İşaretlemeleri düzenle", + "hint": "Mevcut işaretlemeleri tutarlı biçimde hizala, yeniden numaralandır veya yeniden renklendir", + "prompt": "İşaretlemelerimi düzenle: " + }, + "import-cad": { + "label": "Çizim içe aktar", + "hint": "Bir DWG veya DXF çizimini doğru ölçekte altlık olarak içe aktar", + "prompt": "Çizimi içe aktar (DWG veya DXF dosyasının yolu): " + }, + "export-cad": { + "label": "CAD'e aktar", + "hint": "Bu sayfayı veya seçili alanı bir DXF ya da DWG dosyasına yaz", + "prompt": "Bu sayfayı şuraya aktar (DXF veya DWG dosyasının yolu): " } + }, + "groups": { + "document": "Çevir ve özetle", + "reading": "Çizimi okuma", + "measuring": "Ölçüm ve metrajlar", + "markup": "İşaretleme", + "structure": "Taşıyıcı sistem ve kat planı", + "cad": "CAD çizimleri" + }, + "need": { + "document": "Önce bir PDF açın: bu beceri açık belge üzerinde çalışır.", + "selection": "Önce çizimde bir işaretleme veya bir miktar metin seçin, ardından beceriye yeniden tıklayın.", + "scale": "Bu çizimin henüz bir ölçüm ölçeği yok. Önce bir ölçek ayarlayın (örneğin \"Ölçeği kontrol et\" ile) ve yeniden deneyin.", + "input": "Giriş kutusundaki metni iki noktadan sonra tamamlayın, ardından gönderin.", + "selectionOrInput": "Çizimde bir şekil seçin veya neyin ölçüleceğini iki noktadan sonra açıklayın.", + "page": "Geçerli sayfanın değil, diğer sayfanın numarasını girin (1 ile {{max}} arası).", + "twoPages": "Bu belgede yalnızca bir sayfa var. Başka bir dosyayla karşılaştırmak için giriş kutusundaki metni kendi sorunuzla değiştirin." } }, "onlineSymbolLibrary": "Çevrimiçi sembol kitaplığı", diff --git a/open-pdf-studio/js/i18n/locales/uk/common.json b/open-pdf-studio/js/i18n/locales/uk/common.json index 163a58fa6..1128f19fe 100644 --- a/open-pdf-studio/js/i18n/locales/uk/common.json +++ b/open-pdf-studio/js/i18n/locales/uk/common.json @@ -124,7 +124,79 @@ "label": "План поверху", "hint": "Накреслити архітектурний план поверху: стіни, прорізи, приміщення, розміри та обладнання (сантехніка, кухня)", "prompt": "Накресли архітектурний план поверху. Що потрібно накреслити: " + }, + "find-on-sheet": { + "label": "Знайти на аркуші", + "hint": "Знайти термін (назву приміщення, позначку розрізу, символ), перейти на його сторінку й виділити збіги", + "prompt": "Знайди на цьому аркуші: " + }, + "explain-selection": { + "label": "Пояснити виділене", + "hint": "Описати виділену ділянку, текст або позначку простою мовою, з розмірами, якщо задано масштаб вимірювання", + "prompt": "Поясни виділене." + }, + "compare-sheets": { + "label": "Порівняти аркуші", + "hint": "Назвати й позначити, що додано, вилучено або переміщено порівняно з іншою сторінкою", + "prompt": "Порівняй цю сторінку зі сторінкою: " + }, + "measure": { + "label": "Виміряти це", + "hint": "Виміряти виділену фігуру або описаний елемент і подати результат у масштабі креслення", + "prompt": "Виміряй: " + }, + "take-off": { + "label": "Підрахувати обсяги", + "hint": "Порахувати або виміряти елементи одного типу й розмістити результат на аркуші як відомість", + "prompt": "Підрахуй обсяги: " + }, + "check-scale": { + "label": "Перевірити масштаб", + "hint": "Визначити масштаб за основним написом або відомим розміром і задати його", + "prompt": "Перевір масштаб цього креслення." + }, + "review-sheet": { + "label": "Перевірити аркуш", + "hint": "Додати хмарки з нумерованими коментарями до пунктів, на які вказав би перевіряльник", + "prompt": "Перевір цей аркуш щодо: " + }, + "stamp-date": { + "label": "Печатка й дата", + "hint": "Поставити печатку, заповнити поле основного напису або додати примітку про зміну із сьогоднішньою датою", + "prompt": "Постав печатку й дату: " + }, + "clean-up": { + "label": "Упорядкувати позначки", + "hint": "Узгоджено вирівняти, перенумерувати або перефарбувати наявні позначки", + "prompt": "Упорядкуй мої позначки: " + }, + "import-cad": { + "label": "Додати креслення", + "hint": "Імпортувати креслення DWG або DXF як підкладку в правильному масштабі", + "prompt": "Додай креслення (шлях до файлу DWG або DXF): " + }, + "export-cad": { + "label": "Експорт у CAD", + "hint": "Записати цю сторінку або виділену ділянку у файл DXF або DWG", + "prompt": "Експортуй цю сторінку в (шлях до файлу DXF або DWG): " } + }, + "groups": { + "document": "Переклад і підсумок", + "reading": "Читання креслення", + "measuring": "Вимірювання та обсяги", + "markup": "Позначки", + "structure": "Конструкції та план поверху", + "cad": "CAD-креслення" + }, + "need": { + "document": "Спершу відкрийте PDF: ця навичка працює з відкритим документом.", + "selection": "Спершу виберіть позначку або фрагмент тексту на кресленні, а потім знову натисніть навичку.", + "scale": "Це креслення ще не має масштабу вимірювання. Спершу задайте його (наприклад, за допомогою «Перевірити масштаб») і спробуйте знову.", + "input": "Доповніть текст у полі введення після двокрапки, а потім надішліть.", + "selectionOrInput": "Виберіть фігуру на кресленні або опишіть після двокрапки, що потрібно виміряти.", + "page": "Введіть номер іншої сторінки (від 1 до {{max}}), а не поточної.", + "twoPages": "Цей документ має лише одну сторінку. Щоб порівняти з іншим файлом, замініть текст у полі введення власним запитанням." } }, "onlineSymbolLibrary": "Онлайн-бібліотека символів", diff --git a/open-pdf-studio/js/i18n/locales/ur/common.json b/open-pdf-studio/js/i18n/locales/ur/common.json index c48d180b6..a52223ffc 100644 --- a/open-pdf-studio/js/i18n/locales/ur/common.json +++ b/open-pdf-studio/js/i18n/locales/ur/common.json @@ -124,7 +124,79 @@ "label": "فلور پلان", "hint": "تعمیراتی فلور پلان بنائیں: دیواریں، دروازے اور کھڑکیاں، کمرے، پیمائش اور سامان (سینیٹری، باورچی خانہ)", "prompt": "ایک تعمیراتی فلور پلان بنائیں۔ کیا بنانا ہے: " + }, + "find-on-sheet": { + "label": "شیٹ پر تلاش کریں", + "hint": "کوئی اصطلاح (کمرے کا نام، سیکشن کا نشان، علامت) تلاش کریں، اس کے صفحے پر جائیں اور نتائج نمایاں کریں", + "prompt": "اس شیٹ پر تلاش کریں: " + }, + "explain-selection": { + "label": "انتخاب کی وضاحت", + "hint": "منتخب حصے، متن یا نشان کو آسان زبان میں بیان کریں، پیمائشی اسکیل سیٹ ہو تو پیمائشوں کے ساتھ", + "prompt": "انتخاب کی وضاحت کریں۔" + }, + "compare-sheets": { + "label": "شیٹس کا موازنہ", + "hint": "کسی دوسرے صفحے کے مقابلے میں جو کچھ شامل، حذف یا منتقل ہوا اس کی نشاندہی کریں اور نشان لگائیں", + "prompt": "اس صفحے کا موازنہ اس صفحے سے کریں: " + }, + "measure": { + "label": "اسے ناپیں", + "hint": "منتخب شکل یا بیان کردہ عنصر کو ناپیں اور ڈرائنگ کے اسکیل میں بتائیں", + "prompt": "ناپیں: " + }, + "take-off": { + "label": "مقدار نکالیں", + "hint": "ایک ہی قسم کے عناصر گنیں یا ناپیں اور نتیجہ شیٹ پر جدول کی صورت میں رکھیں", + "prompt": "مقدار نکالیں: " + }, + "check-scale": { + "label": "اسکیل چیک کریں", + "hint": "ٹائٹل بلاک یا کسی معلوم پیمائش سے اسکیل معلوم کریں اور اسے سیٹ کریں", + "prompt": "اس ڈرائنگ کا اسکیل چیک کریں۔" + }, + "review-sheet": { + "label": "شیٹ کا جائزہ", + "hint": "ان نکات کے لیے نمبر والے تبصروں کے ساتھ بادل شامل کریں جو کوئی جانچ کنندہ اٹھائے گا", + "prompt": "اس شیٹ کا جائزہ اس حوالے سے لیں: " + }, + "stamp-date": { + "label": "مہر اور تاریخ", + "hint": "مہر لگائیں، ٹائٹل بلاک کا کوئی خانہ پُر کریں یا آج کی تاریخ کے ساتھ نظرثانی نوٹ شامل کریں", + "prompt": "مہر اور تاریخ لگائیں: " + }, + "clean-up": { + "label": "نشانات درست کریں", + "hint": "موجودہ نشانات کو یکساں طور پر سیدھا کریں، دوبارہ نمبر دیں یا دوبارہ رنگ دیں", + "prompt": "میرے نشانات درست کریں: " + }, + "import-cad": { + "label": "ڈرائنگ شامل کریں", + "hint": "DWG یا DXF ڈرائنگ کو درست اسکیل پر زیریں تہہ کے طور پر درآمد کریں", + "prompt": "ڈرائنگ شامل کریں (DWG یا DXF فائل کا راستہ): " + }, + "export-cad": { + "label": "CAD میں برآمد", + "hint": "اس صفحے یا منتخب حصے کو DXF یا DWG فائل میں لکھیں", + "prompt": "یہ صفحہ یہاں برآمد کریں (DXF یا DWG فائل کا راستہ): " } + }, + "groups": { + "document": "ترجمہ اور خلاصہ", + "reading": "ڈرائنگ پڑھنا", + "measuring": "پیمائش اور مقداریں", + "markup": "نشان لگانا", + "structure": "ڈھانچہ اور فلور پلان", + "cad": "CAD ڈرائنگز" + }, + "need": { + "document": "پہلے ایک PDF کھولیں: یہ مہارت کھلی دستاویز پر کام کرتی ہے۔", + "selection": "پہلے ڈرائنگ پر کوئی نشان یا کچھ متن منتخب کریں، پھر مہارت پر دوبارہ کلک کریں۔", + "scale": "اس ڈرائنگ کا ابھی کوئی پیمائشی اسکیل نہیں ہے۔ پہلے ایک اسکیل سیٹ کریں (مثلاً \"اسکیل چیک کریں\" سے) اور دوبارہ کوشش کریں۔", + "input": "ان پٹ باکس میں کولن کے بعد متن مکمل کریں، پھر بھیجیں۔", + "selectionOrInput": "ڈرائنگ پر کوئی شکل منتخب کریں، یا کولن کے بعد بیان کریں کہ کیا ناپنا ہے۔", + "page": "دوسرے صفحے کا نمبر درج کریں (1 سے {{max}} تک)، موجودہ صفحے کا نہیں۔", + "twoPages": "اس دستاویز میں صرف ایک صفحہ ہے۔ کسی دوسری فائل سے موازنہ کرنے کے لیے ان پٹ باکس کا متن اپنے سوال سے بدل دیں۔" } }, "onlineSymbolLibrary": "آن لائن علامتوں کی لائبریری", diff --git a/open-pdf-studio/js/i18n/locales/vi/common.json b/open-pdf-studio/js/i18n/locales/vi/common.json index a0f05062f..ea8068b90 100644 --- a/open-pdf-studio/js/i18n/locales/vi/common.json +++ b/open-pdf-studio/js/i18n/locales/vi/common.json @@ -124,7 +124,79 @@ "label": "Mặt bằng", "hint": "Vẽ mặt bằng kiến trúc: tường, khung cửa, phòng, kích thước và trang thiết bị (vệ sinh, bếp)", "prompt": "Vẽ mặt bằng kiến trúc. Nội dung cần vẽ: " + }, + "find-on-sheet": { + "label": "Tìm trên tờ", + "hint": "Tìm một thuật ngữ (tên phòng, ký hiệu mặt cắt, biểu tượng), chuyển đến trang của nó và tô sáng các kết quả", + "prompt": "Tìm trên tờ này: " + }, + "explain-selection": { + "label": "Giải thích vùng chọn", + "hint": "Mô tả vùng, văn bản hoặc ghi chú đã chọn bằng ngôn ngữ dễ hiểu, kèm kích thước khi đã đặt tỷ lệ đo", + "prompt": "Giải thích vùng chọn." + }, + "compare-sheets": { + "label": "So sánh các tờ", + "hint": "Nêu tên và đánh dấu những gì đã được thêm, xóa hoặc di chuyển so với một trang khác", + "prompt": "So sánh trang này với trang: " + }, + "measure": { + "label": "Đo phần này", + "hint": "Đo hình đã chọn hoặc một cấu kiện được mô tả và báo cáo theo tỷ lệ của bản vẽ", + "prompt": "Đo: " + }, + "take-off": { + "label": "Bóc khối lượng", + "hint": "Đếm hoặc đo các cấu kiện cùng loại và đặt kết quả dưới dạng bảng thống kê trên tờ", + "prompt": "Bóc khối lượng: " + }, + "check-scale": { + "label": "Kiểm tra tỷ lệ", + "hint": "Tìm tỷ lệ từ khung tên hoặc một kích thước đã biết và đặt tỷ lệ đó", + "prompt": "Kiểm tra tỷ lệ của bản vẽ này." + }, + "review-sheet": { + "label": "Rà soát tờ", + "hint": "Thêm các đám mây kèm nhận xét đánh số cho những điểm mà người kiểm tra sẽ nêu ra", + "prompt": "Rà soát tờ này về: " + }, + "stamp-date": { + "label": "Đóng dấu và ghi ngày", + "hint": "Đặt con dấu, điền một ô trong khung tên hoặc thêm ghi chú sửa đổi với ngày hôm nay", + "prompt": "Đóng dấu và ghi ngày: " + }, + "clean-up": { + "label": "Sắp xếp ghi chú", + "hint": "Căn chỉnh, đánh số lại hoặc đổi màu các ghi chú hiện có một cách thống nhất", + "prompt": "Sắp xếp các ghi chú của tôi: " + }, + "import-cad": { + "label": "Đưa bản vẽ vào", + "hint": "Nhập bản vẽ DWG hoặc DXF làm lớp nền theo đúng tỷ lệ", + "prompt": "Đưa bản vẽ vào (đường dẫn tệp DWG hoặc DXF): " + }, + "export-cad": { + "label": "Xuất sang CAD", + "hint": "Ghi trang này hoặc vùng đã chọn ra tệp DXF hoặc DWG", + "prompt": "Xuất trang này ra (đường dẫn tệp DXF hoặc DWG): " } + }, + "groups": { + "document": "Dịch và tóm tắt", + "reading": "Đọc bản vẽ", + "measuring": "Đo đạc và khối lượng", + "markup": "Đánh dấu", + "structure": "Kết cấu và mặt bằng", + "cad": "Bản vẽ CAD" + }, + "need": { + "document": "Hãy mở một tệp PDF trước: kỹ năng này hoạt động trên tài liệu đang mở.", + "selection": "Hãy chọn một ghi chú đánh dấu hoặc một đoạn văn bản trên bản vẽ trước, rồi nhấp lại vào kỹ năng.", + "scale": "Bản vẽ này chưa có tỷ lệ đo. Hãy đặt tỷ lệ trước (ví dụ bằng \"Kiểm tra tỷ lệ\") rồi thử lại.", + "input": "Hãy hoàn thành văn bản trong ô nhập sau dấu hai chấm, rồi gửi.", + "selectionOrInput": "Hãy chọn một hình trên bản vẽ, hoặc mô tả sau dấu hai chấm những gì cần đo.", + "page": "Hãy nhập số của trang kia (1 đến {{max}}), không phải trang hiện tại.", + "twoPages": "Tài liệu này chỉ có một trang. Để so sánh với tệp khác, hãy thay văn bản trong ô nhập bằng câu hỏi của riêng bạn." } }, "onlineSymbolLibrary": "Thư viện ký hiệu trực tuyến", diff --git a/open-pdf-studio/js/i18n/locales/zh/common.json b/open-pdf-studio/js/i18n/locales/zh/common.json index af8c2364f..0a0f5149c 100644 --- a/open-pdf-studio/js/i18n/locales/zh/common.json +++ b/open-pdf-studio/js/i18n/locales/zh/common.json @@ -124,7 +124,79 @@ "label": "平面图", "hint": "绘制建筑平面图:墙体、门窗、房间、尺寸标注和设施(卫浴、厨房)", "prompt": "绘制建筑平面图。要绘制的内容:" + }, + "find-on-sheet": { + "label": "在图纸中查找", + "hint": "查找术语(房间名称、剖切符号、图例符号),跳转到所在页面并高亮匹配项", + "prompt": "在此图纸中查找:" + }, + "explain-selection": { + "label": "解释所选内容", + "hint": "用通俗的语言描述所选区域、文本或标注;若已设置测量比例,则附带尺寸", + "prompt": "解释所选内容。" + }, + "compare-sheets": { + "label": "比较图纸", + "hint": "与另一页相比,指出并标记新增、删除或移动的内容", + "prompt": "将此页与以下页码比较:" + }, + "measure": { + "label": "测量此项", + "hint": "测量所选形状或所描述的构件,并按图纸比例报告结果", + "prompt": "测量:" + }, + "take-off": { + "label": "工程量统计", + "hint": "统计或测量同一类构件,并将结果以明细表形式放置在图纸上", + "prompt": "统计工程量:" + }, + "check-scale": { + "label": "检查比例", + "hint": "从图签或已知尺寸确定比例并进行设置", + "prompt": "检查此图纸的比例。" + }, + "review-sheet": { + "label": "审查图纸", + "hint": "为审图人员会提出的问题添加带编号批注的云线", + "prompt": "审查此图纸,重点:" + }, + "stamp-date": { + "label": "盖章并注明日期", + "hint": "放置印章、填写图签字段或添加带今天日期的修订说明", + "prompt": "盖章并注明日期:" + }, + "clean-up": { + "label": "整理标注", + "hint": "统一对齐、重新编号或重新着色现有标注", + "prompt": "整理我的标注:" + }, + "import-cad": { + "label": "导入图纸", + "hint": "以正确比例将 DWG 或 DXF 图纸导入为底图", + "prompt": "导入图纸(DWG 或 DXF 文件路径):" + }, + "export-cad": { + "label": "导出为 CAD", + "hint": "将此页或所选区域写入 DXF 或 DWG 文件", + "prompt": "将此页导出到(DXF 或 DWG 文件路径):" } + }, + "groups": { + "document": "翻译与总结", + "reading": "识读图纸", + "measuring": "测量与工程量", + "markup": "标注", + "structure": "结构与平面图", + "cad": "CAD 图纸" + }, + "need": { + "document": "请先打开一个 PDF:此技能作用于已打开的文档。", + "selection": "请先在图纸上选择一个标注或一些文本,然后再次点击该技能。", + "scale": "此图纸尚未设置测量比例。请先设置(例如使用“检查比例”),然后重试。", + "input": "请在输入框中冒号后补全文本,然后发送。", + "selectionOrInput": "请在图纸上选择一个形状,或在冒号后描述要测量的内容。", + "page": "请输入另一页的页码(1 到 {{max}}),而不是当前页。", + "twoPages": "此文档只有一页。如需与其他文件比较,请将输入框中的文本替换为你自己的问题。" } }, "onlineSymbolLibrary": "在线符号库", diff --git a/open-pdf-studio/js/plattegrond/skill.js b/open-pdf-studio/js/plattegrond/skill.js index f049265b3..f80d57f7e 100644 --- a/open-pdf-studio/js/plattegrond/skill.js +++ b/open-pdf-studio/js/plattegrond/skill.js @@ -14,6 +14,7 @@ export const PLATTEGROND_SKILL = { id: 'floorplan', + group: 'structure', icon: '🏠', // Naam, uitleg en de tekst in het invoervak staan per taal in de locales // (common.assistant.skills.floorplan); de instructie voor het model is diff --git a/open-pdf-studio/js/solid/components/AssistantPanel.jsx b/open-pdf-studio/js/solid/components/AssistantPanel.jsx index a03986796..be86af314 100644 --- a/open-pdf-studio/js/solid/components/AssistantPanel.jsx +++ b/open-pdf-studio/js/solid/components/AssistantPanel.jsx @@ -11,8 +11,12 @@ // skill instructions themselves stay in one language (assistant-skills.js). import { createSignal, For, Show, createEffect } from 'solid-js'; import { registerAssistantSubmit, registerAssistantMessages, enqueueAssistantQuestion, relayClientActive } from '../../assistant-mcp-relay.js'; -import { ASSISTANT_SKILLS, buildSystemPrompt, skillKeys, skillMessage } from '../../assistant-skills.js'; -import { getActiveDocument } from '../../core/state.js'; +import { + ASSISTANT_SKILL_GROUPS, buildSystemPrompt, skillKeys, skillMessage, skillGroupKey, skillsInGroup, + skillBlocker, skillInputProblem, skillInputRest, +} from '../../assistant-skills.js'; +import { skillContextFromDocument } from '../../assistant-skill-context.js'; +import { getActiveDocument, state } from '../../core/state.js'; import { useTranslation } from '../../i18n/useTranslation.js'; import { LANGUAGES } from '../../i18n/config.js'; import { leesAiSleutel, bewaarAiSleutel, ruimAiSleutelOpAlsGast } from '../../core/ai-sleutel.js'; @@ -89,6 +93,10 @@ export default function AssistantPanel() { const [showKey, setShowKey] = createSignal(false); // A skill chip that put its prefix in the input box and waits for the rest. const [pendingSkill, setPendingSkill] = createSignal(null); + // The skill tab that is shown, and the message of a skill that could not be + // sent yet (no selection, no scale, input missing): { key, params }. + const [activeGroup, setActiveGroup] = createSignal(ASSISTANT_SKILL_GROUPS[0].id); + const [notice, setNotice] = createSignal(null); let messagesEnd, inputEl, keyEl; const activeDocName = () => getActiveDocument()?.fileName || null; @@ -107,6 +115,11 @@ export default function AssistantPanel() { return buildSystemPrompt(currentLanguage(language())); } + // The app state a skill needs, read at the moment of sending. + function skillContext() { + return skillContextFromDocument({ doc: getActiveDocument(), textSelection: state.textSelection }); + } + function saveKey() { const v = (keyEl?.value || '').trim(); try { @@ -126,7 +139,16 @@ export default function AssistantPanel() { } else { shown = (typeof explicit === 'string' ? explicit : input()).trim(); const skill = typeof explicit === 'string' ? null : pendingSkill(); - if (skill) ({ shown, model } = skillMessage(skill, t(skillKeys(skill).prompt), shown)); + if (skill) { + // Check again on sending: the selection or the page may have changed + // since the chip was clicked. A problem keeps the text in the box. + const prompt = t(skillKeys(skill).prompt); + const ctx = skillContext(); + const rest = skillInputRest(skill, prompt, shown); + const problem = rest == null ? null : (skillBlocker(skill, ctx) || skillInputProblem(skill, rest, ctx)); + if (problem) { setNotice(problem); return; } + ({ shown, model } = skillMessage(skill, prompt, shown, ctx)); + } } shown = String(shown || '').trim(); if (!shown || loading()) return; @@ -137,6 +159,7 @@ export default function AssistantPanel() { setMessages((m) => [...m, userMessage]); setInput(''); setPendingSkill(null); + setNotice(null); setLoading(true); // Claude (Anthropic) direct call — the default when a personal Anthropic key // is set via the 🔑 button. @@ -221,14 +244,20 @@ export default function AssistantPanel() { // app language and sends its fixed instruction through the assistant (and thus // the relay to the brain), which executes it via MCP tools. A skill that needs // input pre-fills the box with its localized prefix. + // A skill whose preconditions fail (no document, no selection, no scale) + // shows why in the panel instead of sending. function runSkill(skill) { const prompt = t(skillKeys(skill).prompt); + const ctx = skillContext(); + const blocked = skillBlocker(skill, ctx); + setNotice(blocked); + if (blocked) return; if (skill.needsInput) { setPendingSkill(skill); setInput(prompt); inputEl?.focus(); } else { - send(skillMessage(skill, prompt)); + send(skillMessage(skill, prompt, undefined, ctx)); } } @@ -288,9 +317,28 @@ export default function AssistantPanel() {
+ +
{t(notice().key, notice().params)}
+
+ -
- +
+ + {(group) => ( + + )} + + {t(skillGroupKey(activeGroup()))} +
+
+ {(skill) => (