From ab0103e6bba2b26198b008e05b7412171f700bc8 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 9 Nov 2026 10:44:02 -0600 Subject: [PATCH 01/13] Draw annotation text in every script in exports Text boxes, tooltips and GIF title cards are burned into screenshots by the software rasterizer, whose Lato atlas only covers Latin; Chinese and other scripts came out as "?" in PDF, Word and every other export. Characters the atlas lacks are now drawn from the outlines of an installed font (the same font lists the PDF exporter uses), including CFF fonts such as Noto Sans CJK on Linux. Wrapping breaks between CJK characters. --- core/cff.js | 249 +++++++++++++++++++++ core/pdf-fonts.js | 39 ++-- core/text-raster.js | 243 ++++++++++++++++++-- core/ttf.js | 131 +++++++++-- tests/unit/annotation-text-unicode.test.js | 206 +++++++++++++++++ tests/unit/font-fixtures.js | 129 +++++++++++ tests/unit/pdf-unicode.test.js | 121 +--------- 7 files changed, 950 insertions(+), 168 deletions(-) create mode 100644 core/cff.js create mode 100644 tests/unit/annotation-text-unicode.test.js create mode 100644 tests/unit/font-fixtures.js diff --git a/core/cff.js b/core/cff.js new file mode 100644 index 00000000..20a3e466 --- /dev/null +++ b/core/cff.js @@ -0,0 +1,249 @@ +'use strict'; + +/** + * Glyph outlines from an OpenType font's 'CFF ' table (Type 2 charstrings), + * the format of most Linux CJK fonts (Noto Sans CJK, Source Han Sans). Only + * outlines are read: character mapping and metrics come from the font's + * cmap/hmtx tables (core/ttf.js). Hints are skipped. CID-keyed fonts pick + * their local subroutines through FDSelect. + */ + +function readOffset(buf, at, size) { + let v = 0; + for (let i = 0; i < size; i++) v = v * 256 + buf[at + i]; + return v; +} + +/** A CFF INDEX at `at`: its items as [start, end) byte ranges, and where it ends. */ +function readIndex(buf, at) { + const count = buf.readUInt16BE(at); + if (!count) return { items: [], end: at + 2 }; + const size = buf[at + 2]; + const base = at + 3 + (count + 1) * size - 1; // offsets count from 1 + const items = []; + let prev = readOffset(buf, at + 3, size); + for (let i = 1; i <= count; i++) { + const next = readOffset(buf, at + 3 + i * size, size); + items.push([base + prev, base + next]); + prev = next; + } + return { items, end: base + prev }; +} + +/** A CFF DICT as { operator: operands }; escaped operators are 1200 + the second byte. */ +function readDict(buf, at, end) { + const dict = {}; + let operands = []; + while (at < end) { + const b0 = buf[at]; + if (b0 <= 21) { + const op = b0 === 12 ? 1200 + buf[at + 1] : b0; + at += b0 === 12 ? 2 : 1; + dict[op] = operands; + operands = []; + } else if (b0 === 28) { operands.push(buf.readInt16BE(at + 1)); at += 3; } + else if (b0 === 29) { operands.push(buf.readInt32BE(at + 1)); at += 5; } + else if (b0 === 30) { + let text = ''; + for (at++; at < end; at++) { + const nibbles = [buf[at] >> 4, buf[at] & 15]; + if (nibbles.some((n) => n === 15)) { text += nibbles[0] === 15 ? '' : realNibble(nibbles[0]); at++; break; } + text += realNibble(nibbles[0]) + realNibble(nibbles[1]); + } + operands.push(Number(text) || 0); + } else if (b0 >= 32 && b0 <= 246) { operands.push(b0 - 139); at += 1; } + else if (b0 >= 247 && b0 <= 250) { operands.push((b0 - 247) * 256 + buf[at + 1] + 108); at += 2; } + else if (b0 >= 251 && b0 <= 254) { operands.push(-(b0 - 251) * 256 - buf[at + 1] - 108); at += 2; } + else at += 1; // reserved + } + return dict; +} + +function realNibble(n) { + return n < 10 ? String(n) : ['.', 'E', 'E-', '', '-'][n - 10] || ''; +} + +/** Subroutine numbers are stored relative to a bias that depends on how many there are. */ +function bias(subrs) { + return subrs.length < 1240 ? 107 : subrs.length < 33900 ? 1131 : 32768; +} + +class CffOutlines { + /** @param {Buffer} buf the font file; @param {number} offset where its 'CFF ' table starts. */ + constructor(buf, offset) { + this.buf = buf; + let at = offset + buf[offset + 2]; // header size + at = readIndex(buf, at).end; // Name INDEX + const tops = readIndex(buf, at); + at = readIndex(buf, tops.end).end; // String INDEX + this.globalSubrs = readIndex(buf, at).items; + if (!tops.items.length) throw new Error('CFF table has no fonts'); + const top = readDict(buf, ...tops.items[0]); + if (!top[17]) throw new Error('CFF font has no CharStrings'); + this.charStrings = readIndex(buf, offset + top[17][0]).items; + const localSubrs = (dict) => { + if (!dict[18] || dict[18].length < 2) return []; + const [size, from] = dict[18]; + const priv = readDict(buf, offset + from, offset + from + size); + return priv[19] ? readIndex(buf, offset + from + priv[19][0]).items : []; + }; + if (top[1236] && top[1237]) { // CID-keyed: FDArray + FDSelect + this.fontDicts = readIndex(buf, offset + top[1236][0]).items.map(([s, e]) => localSubrs(readDict(buf, s, e))); + this.fdSelect = readFdSelect(buf, offset + top[1237][0], this.charStrings.length); + } else { + this.fontDicts = [localSubrs(top)]; + this.fdSelect = () => 0; + } + } + + /** Path commands for a glyph, in font units (same format as TrueTypeFont.outline). */ + outline(glyph) { + const range = this.charStrings[glyph]; + if (!range) return []; + const buf = this.buf; + const globals = this.globalSubrs; + const locals = this.fontDicts[this.fdSelect(glyph)] || []; + const path = []; + const stack = []; + let x = 0, y = 0, stems = 0, widthRead = false, done = false; + const takeWidth = (expected) => { if (!widthRead && stack.length > expected) stack.shift(); widthRead = true; }; + const stemHints = () => { takeWidth(stack.length & ~1); stems += stack.length >> 1; stack.length = 0; }; + const moveTo = (nx, ny) => { x = nx; y = ny; path.push(['M', x, y]); }; + const lineTo = (nx, ny) => { x = nx; y = ny; path.push(['L', x, y]); }; + const curveTo = (c1x, c1y, c2x, c2y, nx, ny) => { x = nx; y = ny; path.push(['C', c1x, c1y, c2x, c2y, x, y]); }; + const relCurve = (s, i) => { + const c1x = x + s[i], c1y = y + s[i + 1], c2x = c1x + s[i + 2], c2y = c1y + s[i + 3]; + curveTo(c1x, c1y, c2x, c2y, c2x + s[i + 4], c2y + s[i + 5]); + }; + + const run = (start, end, depth) => { + let at = start; + while (at < end && !done) { + const b0 = buf[at++]; + if (b0 === 28) { stack.push(buf.readInt16BE(at)); at += 2; continue; } + if (b0 >= 32) { + if (b0 <= 246) stack.push(b0 - 139); + else if (b0 <= 250) stack.push((b0 - 247) * 256 + buf[at++] + 108); + else if (b0 <= 254) stack.push(-(b0 - 251) * 256 - buf[at++] - 108); + else { stack.push(buf.readInt32BE(at) / 65536); at += 4; } + continue; + } + const s = stack; + switch (b0) { + case 1: case 3: case 18: case 23: stemHints(); break; // hstem, vstem, hstemhm, vstemhm + case 19: case 20: stemHints(); at += (stems + 7) >> 3; break; // hintmask, cntrmask + case 21: takeWidth(2); moveTo(x + s[0], y + s[1]); s.length = 0; break; // rmoveto + case 22: takeWidth(1); moveTo(x + s[0], y); s.length = 0; break; // hmoveto + case 4: takeWidth(1); moveTo(x, y + s[0]); s.length = 0; break; // vmoveto + case 5: for (let i = 0; i + 1 < s.length; i += 2) lineTo(x + s[i], y + s[i + 1]); s.length = 0; break; // rlineto + case 6: case 7: { // hlineto, vlineto: alternating + let horizontal = b0 === 6; + for (const v of s) { if (horizontal) lineTo(x + v, y); else lineTo(x, y + v); horizontal = !horizontal; } + s.length = 0; + break; + } + case 8: for (let i = 0; i + 5 < s.length; i += 6) relCurve(s, i); s.length = 0; break; // rrcurveto + case 24: { // rcurveline + let i = 0; + for (; i + 5 < s.length - 2; i += 6) relCurve(s, i); + if (i + 1 < s.length) lineTo(x + s[i], y + s[i + 1]); + s.length = 0; + break; + } + case 25: { // rlinecurve + let i = 0; + for (; i + 1 < s.length - 6; i += 2) lineTo(x + s[i], y + s[i + 1]); + if (i + 5 < s.length) relCurve(s, i); + s.length = 0; + break; + } + case 26: case 27: { // vvcurveto, hhcurveto + let i = 0, extra = 0; + if (s.length % 2) extra = s[i++]; + for (; i + 3 < s.length; i += 4) { + if (b0 === 26) { + const c1x = x + extra, c1y = y + s[i], c2x = c1x + s[i + 1], c2y = c1y + s[i + 2]; + curveTo(c1x, c1y, c2x, c2y, c2x, c2y + s[i + 3]); + } else { + const c1x = x + s[i], c1y = y + extra, c2x = c1x + s[i + 1], c2y = c1y + s[i + 2]; + curveTo(c1x, c1y, c2x, c2y, c2x + s[i + 3], c2y); + } + extra = 0; + } + s.length = 0; + break; + } + case 30: case 31: { // vhcurveto, hvcurveto: alternating start tangents + let horizontal = b0 === 31; + for (let i = 0; i + 3 < s.length; horizontal = !horizontal) { + const last = s.length - i === 5 ? s[i + 4] : 0; + if (horizontal) { + const c1x = x + s[i], c1y = y, c2x = c1x + s[i + 1], c2y = c1y + s[i + 2]; + curveTo(c1x, c1y, c2x, c2y, c2x + last, c2y + s[i + 3]); + } else { + const c1x = x, c1y = y + s[i], c2x = c1x + s[i + 1], c2y = c1y + s[i + 2]; + curveTo(c1x, c1y, c2x, c2y, c2x + s[i + 3], c2y + last); + } + i += s.length - i === 5 ? 5 : 4; + } + s.length = 0; + break; + } + case 10: case 29: { // callsubr, callgsubr + const subrs = b0 === 10 ? locals : globals; + const sub = subrs[s.pop() + bias(subrs)]; + if (sub && depth < 10) run(sub[0], sub[1], depth + 1); + break; + } + case 11: return; // return + case 14: done = true; return; // endchar (accented-character "seac" arguments are ignored) + case 12: flex(buf[at++], s); s.length = 0; break; + default: s.length = 0; break; + } + } + }; + + const flex = (op, s) => { + const x0 = x, y0 = y; + if (op === 35 && s.length >= 12) { relCurve(s, 0); relCurve(s, 6); } // flex + else if (op === 34 && s.length >= 7) { // hflex + const c1x = x + s[0], c2x = c1x + s[1], c2y = y + s[2]; + curveTo(c1x, y, c2x, c2y, c2x + s[3], c2y); + const c3x = x + s[4], c4x = c3x + s[5]; + curveTo(c3x, c2y, c4x, y0, c4x + s[6], y0); + } else if (op === 36 && s.length >= 9) { // hflex1 + const c1x = x + s[0], c1y = y + s[1], c2x = c1x + s[2], c2y = c1y + s[3]; + curveTo(c1x, c1y, c2x, c2y, c2x + s[4], c2y); + const c3x = x + s[5], c4x = c3x + s[6], c4y = c2y + s[7]; + curveTo(c3x, c2y, c4x, c4y, c4x + s[8], y0); + } else if (op === 37 && s.length >= 11) { // flex1 + const c1x = x + s[0], c1y = y + s[1], c2x = c1x + s[2], c2y = c1y + s[3]; + curveTo(c1x, c1y, c2x, c2y, c2x + s[4], c2y + s[5]); + const c3x = x + s[6], c3y = y + s[7], c4x = c3x + s[8], c4y = c3y + s[9]; + if (Math.abs(c4x - x0) > Math.abs(c4y - y0)) curveTo(c3x, c3y, c4x, c4y, c4x + s[10], y0); + else curveTo(c3x, c3y, c4x, c4y, x0, c4y + s[10]); + } + }; + + run(range[0], range[1], 0); + return path; + } +} + +function readFdSelect(buf, at, glyphCount) { + const format = buf[at]; + if (format === 0) return (glyph) => (glyph < glyphCount ? buf[at + 1 + glyph] : 0); + if (format !== 3) return () => 0; + const ranges = buf.readUInt16BE(at + 1); + return (glyph) => { + let lo = 0, hi = ranges - 1, fd = 0; + while (lo <= hi) { // last range whose first glyph is <= glyph + const mid = (lo + hi) >> 1; + if (buf.readUInt16BE(at + 3 + mid * 3) <= glyph) { fd = buf[at + 5 + mid * 3]; lo = mid + 1; } + else hi = mid - 1; + } + return fd; + }; +} + +module.exports = { CffOutlines, readIndex, readDict }; diff --git a/core/pdf-fonts.js b/core/pdf-fonts.js index 37bab760..7e9fdaf3 100644 --- a/core/pdf-fonts.js +++ b/core/pdf-fonts.js @@ -6,10 +6,11 @@ const { execFileSync } = require('node:child_process'); const { TrueTypeFont } = require('./ttf'); /** - * Installed fonts for PDF text the built-in PDF fonts can't show (anything - * outside WinAnsiEncoding). Candidates are tried in order and only loaded - * when an earlier one lacks a character, so a Western guide reads no font - * files at all. + * Installed fonts for text the built-in fonts can't show: PDF text outside + * WinAnsiEncoding, and annotation text outside the Lato atlas that is drawn + * into screenshots (core/text-raster.js). Candidates are tried in order and + * only loaded when an earlier one lacks a character, so a Western guide reads + * no font files at all. */ // Windows font files as [regular, bold] pairs, grouped by script. Every file @@ -22,6 +23,8 @@ const WINDOWS_FONTS = { other: [['LeelawUI.ttf', 'LeelaUIb.ttf'], ['Nirmala.ttc', null], ['Nirmala.ttf', 'NirmalaB.ttf'], ['seguisym.ttf', null]], }; +const loadFont = (face, options) => new TrueTypeFont(fs.readFileSync(face.file), face.index, options); + const HAN_ORDER = { zh: ['zh', 'ja', 'ko'], ja: ['ja', 'zh', 'ko'], ko: ['ko', 'zh', 'ja'] }; /** @@ -55,35 +58,41 @@ function windowsCandidates(env, prefer) { const FC_LANG = { zh: 'zh-cn', ja: 'ja', ko: 'ko' }; -/** fontconfig's fallback list for a pattern: TrueType faces, best match first. */ -function fcMatch(pattern, run) { +/** + * fontconfig's fallback list for a pattern, best match first: TrueType faces, + * plus CFF ones when `cff` (they can be drawn but not embedded in a PDF). + */ +function fcMatch(pattern, run, cff) { try { const out = run('fc-match', ['-s', '-f', '%{file}\t%{index}\t%{fontformat}\n', pattern], { encoding: 'utf8', timeout: 5000 }); return out.split('\n').map((line) => line.split('\t')) - .filter(([file, , format]) => file && format === 'TrueType') + .filter(([file, , format]) => file && (format === 'TrueType' || (cff && format === 'CFF'))) .map(([file, index]) => ({ file, index: Number(index) || 0 })); } catch { return []; } } -function linuxCandidates(prefer, run) { +function linuxCandidates(prefer, run, cff) { // Western sans first (Latin, Cyrillic, Greek), then fonts for the guide's // East Asian language. fc-match -s lists every installed font, so keep the // first few dozen: past that a font rarely adds coverage. const seen = new Set(); const unique = (list) => list.filter(({ file, index }) => !seen.has(`${file}#${index}`) && seen.add(`${file}#${index}`)); - const regular = unique([...fcMatch('sans-serif', run).slice(0, 4), ...fcMatch(`sans-serif:lang=${FC_LANG[prefer]}`, run)]).slice(0, 40); + const regular = unique([...fcMatch('sans-serif', run, cff).slice(0, 4), ...fcMatch(`sans-serif:lang=${FC_LANG[prefer]}`, run, cff)]).slice(0, 40); seen.clear(); - const bold = unique([...fcMatch('sans-serif:weight=bold', run).slice(0, 4), ...fcMatch(`sans-serif:weight=bold:lang=${FC_LANG[prefer]}`, run)]).slice(0, 40); + const bold = unique([...fcMatch('sans-serif:weight=bold', run, cff).slice(0, 4), ...fcMatch(`sans-serif:weight=bold:lang=${FC_LANG[prefer]}`, run, cff)]).slice(0, 40); return [...regular.map((face) => ({ regular: face, bold: null })), ...bold.map((face) => ({ regular: null, bold: face }))]; } -/** Installed fonts to try, in order, as { regular, bold } face pairs. */ -function systemFontCandidates({ text = '', platform = process.platform, env = process.env, run = execFileSync } = {}) { +/** + * Installed fonts to try, in order, as { regular, bold } face pairs. `cff` + * also lists CFF-outline fonts (most Linux CJK fonts), for drawing only. + */ +function systemFontCandidates({ text = '', platform = process.platform, env = process.env, run = execFileSync, cff = false } = {}) { const prefer = hanPreference(text); if (platform === 'win32') return windowsCandidates(env, prefer); - if (platform === 'linux') return linuxCandidates(prefer, run); + if (platform === 'linux') return linuxCandidates(prefer, run, cff); return []; } @@ -94,7 +103,7 @@ function systemFontCandidates({ text = '', platform = process.platform, env = pr * one, called only when the first such character turns up. */ class FallbackFonts { - constructor(candidates, { load = (face) => new TrueTypeFont(fs.readFileSync(face.file), face.index) } = {}) { + constructor(candidates, { load = loadFont } = {}) { this.pending = typeof candidates === 'function' ? candidates : null; this.candidates = this.pending ? [] : candidates; this.load = load; @@ -131,4 +140,4 @@ class FallbackFonts { } } -module.exports = { FallbackFonts, systemFontCandidates, hanPreference }; +module.exports = { FallbackFonts, systemFontCandidates, hanPreference, loadFont }; diff --git a/core/text-raster.js b/core/text-raster.js index 28108d02..753a3efa 100644 --- a/core/text-raster.js +++ b/core/text-raster.js @@ -3,12 +3,19 @@ const fs = require('node:fs'); const path = require('node:path'); const { decodePng } = require('./png'); +const { FallbackFonts, systemFontCandidates, hanPreference, loadFont } = require('./pdf-fonts'); +const { breakChunks } = require('./pdf'); /** * Anti-aliased text for the software rasterizer. Glyphs come from Lato * atlases rendered at 64px (see scripts/make-font-atlas.js) and are * resampled to the requested size: box-filtered when shrinking, bilinear - * when enlarging. Characters outside the atlas render as '?'. + * when enlarging. Characters outside the atlas (Chinese, Japanese, Korean, + * Thai, Devanagari, ...) are drawn from the outlines of an installed font + * (core/pdf-fonts.js), the way a browser falls back to another font. + * Characters no installed font has render as '?'. Each character is drawn + * on its own: scripts that need shaping or right-to-left ordering are not + * joined or reordered. */ const FONT_DIR = path.join(__dirname, 'fonts'); @@ -28,15 +35,81 @@ function face(weight = 'regular') { return faces.get(id); } -function glyph(f, ch) { - return f.glyphs[ch.codePointAt(0)] || f.glyphs[63]; +// ---- installed fonts for characters the atlas lacks ------------------------ + +// Han characters are drawn differently in Chinese, Japanese and Korean fonts, +// so each preference keeps its own fallback list. Font files are shared. +const loadedFonts = new Map(); +function loadShared(face) { + const key = `${face.file}#${face.index}`; + if (!loadedFonts.has(key)) { + try { loadedFonts.set(key, loadFont(face, { cff: true })); } catch (err) { loadedFonts.set(key, err); } + } + const font = loadedFonts.get(key); + if (font instanceof Error) throw font; + return font; } +const HAN_SAMPLE = { zh: '', ja: 'あ', ko: '가' }; +const defaultFallbacks = (prefer) => new FallbackFonts( + () => systemFontCandidates({ text: HAN_SAMPLE[prefer], cff: true }), { load: loadShared }); +let makeFallbacks = defaultFallbacks; +const fallbacks = new Map(); -function lineWidth(line, sizePx, weight) { +function fallbackFonts(prefer) { + if (!fallbacks.has(prefer)) fallbacks.set(prefer, makeFallbacks(prefer)); + return fallbacks.get(prefer); +} + +/** + * Replace where fallback fonts come from: `factory(prefer)` returns a + * FallbackFonts (or null for none), prefer being 'zh', 'ja' or 'ko'. No + * argument restores the installed fonts. For tests. + */ +function setFallbackFonts(factory) { + makeFallbacks = factory || defaultFallbacks; + fallbacks.clear(); +} + +// Characters that never draw anything (joiners, variation selectors, BOM). +const INVISIBLE = /^[\u00ad\u200b-\u200f\u2060-\u2064\ufe00-\ufe0f\ufeff]$/; +const outlines = new WeakMap(); // font -> Map(glyph -> path) + +function outlineOf(font, glyph) { + if (!outlines.has(font)) outlines.set(font, new Map()); + const cache = outlines.get(font); + if (!cache.has(glyph)) { + let path = []; + try { path = font.outline(glyph); } catch { path = []; } // a damaged glyph draws nothing + cache.set(glyph, path); + } + return cache.get(glyph); +} + +/** + * The glyphs of one line: { atlas } entries from the Lato atlas, or + * { font, glyph } from an installed font, each with its advance in ems. + */ +function glyphRun(line, weight) { const f = face(weight); + const run = []; + let fonts; + for (const ch of line) { + const cp = ch.codePointAt(0); + const atlas = f.glyphs[cp]; + if (atlas) { run.push({ atlas, advance: atlas[4] / f.size }); continue; } + if (INVISIBLE.test(ch)) continue; + if (fonts === undefined) fonts = fallbackFonts(hanPreference(line)); + const hit = fonts && fonts.fontFor(cp, weight === 'bold'); + if (hit) run.push({ font: hit.font, glyph: hit.glyph, advance: hit.font.advance(hit.glyph) / hit.font.unitsPerEm }); + else run.push({ atlas: f.glyphs[63], advance: f.glyphs[63][4] / f.size }); + } + return run; +} + +function lineWidth(line, sizePx, weight) { let w = 0; - for (const ch of line) w += glyph(f, ch)[4]; - return (w * sizePx) / f.size; + for (const g of glyphRun(String(line), weight)) w += g.advance; + return w * sizePx; } function measureText(text, sizePx, weight) { @@ -86,13 +159,136 @@ function blend(img, x, y, color, cov) { d[p + 3] = Math.max(d[p + 3], Math.round(255 * a)); } +// ---- outline glyphs ---------------------------------------------------------- + +/** Flatten path commands (font units, y up) to closed polygons in pixels. */ +function flatten(path, ox, baseline, scale) { + const polys = []; + let poly = null, px = 0, py = 0; + const to = (x, y) => [ox + x * scale, baseline - y * scale]; + const curve = (pts, n) => { + for (let i = 1; i <= n; i++) { + const t = i / n, u = 1 - t; + const x = pts.length === 3 + ? u * u * pts[0][0] + 2 * u * t * pts[1][0] + t * t * pts[2][0] + : u * u * u * pts[0][0] + 3 * u * u * t * pts[1][0] + 3 * u * t * t * pts[2][0] + t * t * t * pts[3][0]; + const y = pts.length === 3 + ? u * u * pts[0][1] + 2 * u * t * pts[1][1] + t * t * pts[2][1] + : u * u * u * pts[0][1] + 3 * u * u * t * pts[1][1] + 3 * u * t * t * pts[2][1] + t * t * t * pts[3][1]; + poly.push(x, y); + } + }; + // Enough segments to keep each one within ~0.2px of the true curve. + const steps = (dev) => Math.max(1, Math.min(64, Math.ceil(Math.sqrt(dev / 0.8)))); + for (const [op, ...v] of path) { + if (op === 'M') { poly = []; polys.push(poly); [px, py] = to(v[0], v[1]); poly.push(px, py); continue; } + if (!poly) continue; + if (op === 'L') { [px, py] = to(v[0], v[1]); poly.push(px, py); } + else if (op === 'Q') { + const c = to(v[0], v[1]), e = to(v[2], v[3]); + curve([[px, py], c, e], steps(Math.hypot(px - 2 * c[0] + e[0], py - 2 * c[1] + e[1]))); + [px, py] = e; + } else if (op === 'C') { + const c1 = to(v[0], v[1]), c2 = to(v[2], v[3]), e = to(v[4], v[5]); + const dev = 3 * Math.max(Math.hypot(px - 2 * c1[0] + c2[0], py - 2 * c1[1] + c2[1]), + Math.hypot(c1[0] - 2 * c2[0] + e[0], c1[1] - 2 * c2[1] + e[1])); + curve([[px, py], c1, c2, e], steps(dev)); + [px, py] = e; + } + } + return polys; +} + +/** + * Accumulate one edge's signed area into `acc` (row-major, `w` wide): after a + * running sum along each row, every cell holds its exact coverage. + */ +function accumulateEdge(acc, w, h, x0, y0, x1, y1) { + if (y0 === y1) return; + let dir = 1; + if (y0 > y1) { dir = -1; [x0, y0, x1, y1] = [x1, y1, x0, y0]; } + const dxdy = (x1 - x0) / (y1 - y0); + let x = x0; + if (y0 < 0) { x -= y0 * dxdy; y0 = 0; } + const yEnd = Math.min(h, Math.ceil(y1)); + for (let y = Math.floor(y0); y < yEnd; y++) { + const dy = Math.min(y + 1, y1) - Math.max(y, y0); + const xNext = x + dxdy * dy; + const d = dy * dir; + const lo = Math.max(0, Math.min(x, xNext)), hi = Math.min(w, Math.max(x, xNext)); + const row = y * w; + const loFloor = Math.floor(lo), hiCeil = Math.ceil(hi); + if (hiCeil <= loFloor + 1) { + const mid = (lo + hi) / 2 - loFloor; + acc[row + loFloor] += d * (1 - mid); + acc[row + loFloor + 1] += d * mid; + } else { + const s = 1 / (hi - lo); + const loFrac = lo - loFloor; + const a0 = 0.5 * s * (1 - loFrac) ** 2; + const hiFrac = hi - hiCeil + 1; + const aEnd = 0.5 * s * hiFrac ** 2; + acc[row + loFloor] += d * a0; + if (hiCeil === loFloor + 2) { + acc[row + loFloor + 1] += d * (1 - a0 - aEnd); + } else { + const a1 = s * (1.5 - loFrac); + acc[row + loFloor + 1] += d * (a1 - a0); + for (let xi = loFloor + 2; xi < hiCeil - 1; xi++) acc[row + xi] += d * s; + const a2 = a1 + (hiCeil - loFloor - 3) * s; + acc[row + hiCeil - 1] += d * (1 - a2 - aEnd); + } + acc[row + hiCeil] += d * aEnd; + } + x = xNext; + } +} + +/** Fill a glyph outline with its origin at (ox, baseline). */ +function fillOutline(img, path, ox, baseline, scale, color) { + const polys = flatten(path, ox, baseline, scale); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const poly of polys) { + for (let i = 0; i < poly.length; i += 2) { + minX = Math.min(minX, poly[i]); maxX = Math.max(maxX, poly[i]); + minY = Math.min(minY, poly[i + 1]); maxY = Math.max(maxY, poly[i + 1]); + } + } + if (!(maxX > minX && maxY > minY)) return; + const bx = Math.floor(minX), by = Math.floor(minY); + const w = Math.ceil(maxX) - bx + 2, h = Math.ceil(maxY) - by + 1; + if (bx >= img.width || by >= img.height || bx + w <= 0 || by + h <= 0 || w * h > 16e6) return; + const acc = new Float32Array(w * h + 2); + for (const poly of polys) { + const n = poly.length; + for (let i = 0; i < n; i += 2) { + const j = (i + 2) % n; // the last point closes back to the first + accumulateEdge(acc, w, h, poly[i] - bx, poly[i + 1] - by, poly[j] - bx, poly[j + 1] - by); + } + } + for (let y = 0; y < h; y++) { + let sum = 0; + for (let x = 0; x < w; x++) { + sum += acc[y * w + x]; + const cov = Math.min(1, Math.abs(sum)); + if (cov > 0.002) blend(img, bx + x, by + y, color, cov * 255); + } + } +} + /** Draw one line with its line box's top-left at (x, top). */ function drawLine(img, x, top, line, sizePx, color, weight) { const f = face(weight); const k = sizePx / f.size; let pen = x; - for (const ch of line) { - const [gx, gy, gw, left, advance] = glyph(f, ch); + for (const g of glyphRun(line, weight)) { + if (g.font) { + // Installed-font glyphs sit on the atlas baseline, scaled to the same em size. + fillOutline(img, outlineOf(g.font, g.glyph), pen, top + f.ascent * k, sizePx / g.font.unitsPerEm, color); + pen += g.advance * sizePx; + continue; + } + const [gx, gy, gw, left, advance] = g.atlas; const ox = pen + left * k; const dx0 = Math.floor(ox), dx1 = Math.ceil(ox + gw * k); const dy0 = Math.floor(top), dy1 = Math.ceil(top + f.cellH * k); @@ -136,28 +332,35 @@ function drawTextCentered(img, cx, cy, text, sizePx, color, { weight = 'regular' function fitText(text, sizePx, maxWidth, { weight = 'regular' } = {}) { const t = String(text); if (lineWidth(t, sizePx, weight) <= maxWidth) return t; - let lo = 0, hi = t.length; + const chars = Array.from(t); // never split a character outside the BMP + const head = (n) => chars.slice(0, n).join('').trimEnd(); + let lo = 0, hi = chars.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); - if (lineWidth(`${t.slice(0, mid).trimEnd()}…`, sizePx, weight) <= maxWidth) lo = mid; + if (lineWidth(`${head(mid)}…`, sizePx, weight) <= maxWidth) lo = mid; else hi = mid - 1; } - return `${t.slice(0, lo).trimEnd()}…`; + return `${head(lo)}…`; } -/** Greedy word wrap; the last allowed line is ellipsized. */ +/** + * Greedy word wrap that also breaks between Chinese, Japanese and Korean + * characters; the last allowed line is ellipsized. + */ function wrapText(text, sizePx, maxWidth, { weight = 'regular', maxLines = Infinity } = {}) { const lines = []; for (const para of String(text).split('\n')) { let line = ''; for (const word of para.split(/\s+/).filter(Boolean)) { - const next = line ? `${line} ${word}` : word; - if (line && lineWidth(next, sizePx, weight) > maxWidth) { - lines.push(line); - line = word; - } else { - line = next; - } + breakChunks(word).forEach((chunk, i) => { + const next = line ? `${line}${i ? '' : ' '}${chunk}` : chunk; + if (line && lineWidth(next, sizePx, weight) > maxWidth) { + lines.push(line); + line = chunk; + } else { + line = next; + } + }); } lines.push(line); } @@ -167,4 +370,4 @@ function wrapText(text, sizePx, maxWidth, { weight = 'regular', maxLines = Infin return kept.map((l, i) => (i < maxLines - 1 ? fitText(l, sizePx, maxWidth, { weight }) : l)); } -module.exports = { measureText, drawText, drawTextCentered, fitText, wrapText, lineWidth }; +module.exports = { measureText, drawText, drawTextCentered, fitText, wrapText, lineWidth, setFallbackFonts }; diff --git a/core/ttf.js b/core/ttf.js index e83cbe4f..ac068368 100644 --- a/core/ttf.js +++ b/core/ttf.js @@ -1,14 +1,17 @@ 'use strict'; /** - * Just enough TrueType to embed system fonts in PDFs: read a .ttf or one face - * of a .ttc, map characters to glyphs, report advance widths and metrics, and - * write a subset holding only the glyphs a document uses. Glyph ids are kept - * (unused glyphs become empty), so a PDF can address glyphs by their original - * id through CIDToGIDMap /Identity. Only glyf-outline fonts are supported; - * CFF ("OTTO") fonts are rejected. + * Just enough TrueType to use system fonts: read a .ttf or one face of a + * .ttc, map characters to glyphs, report advance widths and metrics, read + * glyph outlines (for drawing text into screenshots), and write a subset + * holding only the glyphs a PDF uses. Glyph ids are kept (unused glyphs become + * empty), so a PDF can address glyphs by their original id through + * CIDToGIDMap /Identity. CFF ("OTTO") fonts are rejected unless `cff: true` + * is passed; they can then be drawn from (core/cff.js) but not subset. */ +const { CffOutlines } = require('./cff'); + const TTC_TAG = 0x74746366; // 'ttcf' const TRUETYPE_VERSIONS = new Set([0x00010000, 0x74727565]); // 1.0, 'true' // Tables a PDF viewer needs to draw TrueType glyphs (PDF 32000-1, 9.9). @@ -16,10 +19,12 @@ const SUBSET_TABLES = ['cvt ', 'fpgm', 'glyf', 'head', 'hhea', 'hmtx', 'loca', ' const tagAt = (buf, at) => buf.toString('latin1', at, at + 4); -function tableDirectory(buf, at) { +const OTTO = 0x4f54544f; // 'OTTO', OpenType with CFF outlines + +function tableDirectory(buf, at, cff) { const version = buf.readUInt32BE(at); - if (version === 0x4f54544f) throw new Error('CFF (OpenType) outlines are not supported'); - if (!TRUETYPE_VERSIONS.has(version)) throw new Error('not a TrueType font'); + if (version === OTTO && !cff) throw new Error('CFF (OpenType) outlines are not supported'); + if (version !== OTTO && !TRUETYPE_VERSIONS.has(version)) throw new Error('not a TrueType font'); const tables = {}; const count = buf.readUInt16BE(at + 4); for (let i = 0; i < count; i++) { @@ -29,7 +34,8 @@ function tableDirectory(buf, at) { if (offset + length > buf.length) throw new Error('font table runs past the end of the file'); tables[tagAt(buf, rec)] = { offset, length }; } - for (const tag of ['glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp']) { + const outlines = version === OTTO ? ['CFF '] : ['glyf', 'loca']; + for (const tag of [...outlines, 'head', 'hhea', 'hmtx', 'maxp']) { if (!tables[tag]) throw new Error(`font has no ${tag} table`); } return tables; @@ -109,8 +115,11 @@ function readPostScriptName(buf, name) { } class TrueTypeFont { - /** @param {Buffer} buf a .ttf, or a .ttc with `index` choosing the face. */ - constructor(buf, index = 0) { + /** + * @param {Buffer} buf a .ttf, or a .ttc with `index` choosing the face. + * @param {{ cff?: boolean }} [options] cff: also accept CFF-outline fonts. + */ + constructor(buf, index = 0, { cff = false } = {}) { let at = 0; if (buf.readUInt32BE(0) === TTC_TAG) { const faces = buf.readUInt32BE(8); @@ -118,7 +127,8 @@ class TrueTypeFont { at = buf.readUInt32BE(12 + index * 4); } this.buf = buf; - this.tables = tableDirectory(buf, at); + this.tables = tableDirectory(buf, at, cff); + this.cff = this.tables['CFF '] && !this.tables.glyf ? new CffOutlines(buf, this.tables['CFF '].offset) : null; const head = this.tables.head.offset; this.unitsPerEm = buf.readUInt16BE(head + 18); this.bbox = [0, 2, 4, 6].map((d) => buf.readInt16BE(head + 36 + d)); @@ -157,6 +167,77 @@ class TrueTypeFont { return [glyf + start, glyf + Math.max(start, end)]; } + /** + * A glyph's outline in font units (y up), as path commands: ['M', x, y], + * ['L', x, y], ['Q', cx, cy, x, y] and ['C', c1x, c1y, c2x, c2y, x, y]. + * Each 'M' starts a new closed contour. + */ + outline(glyph, depth = 0) { + if (this.cff) return this.cff.outline(glyph); + if (glyph >= this.glyphCount || depth > 8) return []; + const [start, end] = this.glyphRange(glyph); + if (end - start < 10) return []; + const buf = this.buf; + const contours = buf.readInt16BE(start); + if (contours < 0) return this.compositeOutline(start, depth); + const ends = []; + for (let i = 0; i < contours; i++) ends.push(buf.readUInt16BE(start + 10 + i * 2)); + const count = contours ? ends[contours - 1] + 1 : 0; + let at = start + 10 + contours * 2; + at += 2 + buf.readUInt16BE(at); // skip hinting instructions + const flags = new Uint8Array(count); + for (let i = 0; i < count;) { + const flag = buf[at++]; + flags[i++] = flag; + if (flag & 0x8) for (let repeat = buf[at++]; repeat > 0 && i < count; repeat--) flags[i++] = flag; + } + const coords = (short, same) => { + const out = new Array(count); + let v = 0; + for (let i = 0; i < count; i++) { + if (flags[i] & short) { const d = buf[at++]; v += flags[i] & same ? d : -d; } + else if (!(flags[i] & same)) { v += buf.readInt16BE(at); at += 2; } + out[i] = v; + } + return out; + }; + const xs = coords(0x2, 0x10); + const ys = coords(0x4, 0x20); + const path = []; + let first = 0; + for (const last of ends) { + quadraticContour(path, xs, ys, flags, first, last); + first = last + 1; + } + return path; + } + + compositeOutline(start, depth) { + const buf = this.buf; + const path = []; + let at = start + 10; + for (;;) { + const flags = buf.readUInt16BE(at); + const glyph = buf.readUInt16BE(at + 2); + at += 4; + let dx = 0, dy = 0; + if (flags & 0x1) { dx = buf.readInt16BE(at); dy = buf.readInt16BE(at + 2); at += 4; } + else { dx = buf.readInt8(at); dy = buf.readInt8(at + 1); at += 2; } + if (!(flags & 0x2)) { dx = 0; dy = 0; } // point-matched placement: rare, drawn unshifted + const f2dot14 = (o) => buf.readInt16BE(o) / 16384; + let a = 1, b = 0, c = 0, d = 1; + if (flags & 0x8) { a = d = f2dot14(at); at += 2; } + else if (flags & 0x40) { a = f2dot14(at); d = f2dot14(at + 2); at += 4; } + else if (flags & 0x80) { a = f2dot14(at); b = f2dot14(at + 2); c = f2dot14(at + 4); d = f2dot14(at + 6); at += 8; } + for (const [op, ...pts] of this.outline(glyph, depth + 1)) { + const moved = []; + for (let i = 0; i < pts.length; i += 2) moved.push(a * pts[i] + c * pts[i + 1] + dx, b * pts[i] + d * pts[i + 1] + dy); + path.push([op, ...moved]); + } + if (!(flags & 0x20)) return path; + } + } + /** Glyphs a composite glyph is built from (they must be embedded too). */ components(glyph) { const [start, end] = this.glyphRange(glyph); @@ -179,6 +260,7 @@ class TrueTypeFont { * they reference); every other glyph id stays valid but empty. */ subset(glyphs) { + if (this.cff) throw new Error('CFF fonts cannot be subset'); const keep = new Set([0]); const queue = [...glyphs]; while (queue.length) { @@ -211,6 +293,29 @@ class TrueTypeFont { } } +/** One TrueType contour (on- and off-curve points) as M/L/Q commands. */ +function quadraticContour(path, xs, ys, flags, first, last) { + if (last < first) return; + const on = (i) => flags[i] & 1; + const mid = (i, j) => [(xs[i] + xs[j]) / 2, (ys[i] + ys[j]) / 2]; + let from = first, to = last, start; + if (on(first)) { start = [xs[first], ys[first]]; from = first + 1; } + else if (on(last)) { start = [xs[last], ys[last]]; to = last - 1; } + else start = mid(first, last); + path.push(['M', ...start]); + let ctrl = null; + for (let i = from; i <= to; i++) { + if (on(i)) { + path.push(ctrl ? ['Q', ...ctrl, xs[i], ys[i]] : ['L', xs[i], ys[i]]); + ctrl = null; + } else { + if (ctrl) path.push(['Q', ...ctrl, (ctrl[0] + xs[i]) / 2, (ctrl[1] + ys[i]) / 2]); + ctrl = [xs[i], ys[i]]; + } + } + path.push(ctrl ? ['Q', ...ctrl, ...start] : ['L', ...start]); +} + function checksum(buf) { let sum = 0; const padded = buf.length % 4 ? Buffer.concat([buf, Buffer.alloc(4 - (buf.length % 4))]) : buf; diff --git a/tests/unit/annotation-text-unicode.test.js b/tests/unit/annotation-text-unicode.test.js new file mode 100644 index 00000000..5cb8a595 --- /dev/null +++ b/tests/unit/annotation-text-unicode.test.js @@ -0,0 +1,206 @@ +'use strict'; + +// Annotation text burned into exported screenshots (text boxes, tooltips, +// GIF title cards) must show every script, not just the Lato atlas. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { TrueTypeFont } = require('../../core/ttf'); +const { FallbackFonts, systemFontCandidates } = require('../../core/pdf-fonts'); +const text = require('../../core/text-raster'); +const raster = require('../../core/raster'); +const { table, makeFont } = require('./font-fixtures'); + +const CHINESE = '中打开设置然后重置密码按钮'; +const JAPANESE = '設定を開く'; + +/** One contour: on (0,0), off (500,1000), on (1000,0). */ +function curveGlyph() { + return table(29, (b) => { + b.writeInt16BE(1, 0); b.writeInt16BE(1000, 6); b.writeInt16BE(1000, 8); + b.writeUInt16BE(2, 10); // last point + b.writeUInt16BE(0, 12); // no instructions + b[14] = 1; b[15] = 0; b[16] = 1; // on, off, on; 16-bit deltas + [0, 500, 500].forEach((dx, i) => b.writeInt16BE(dx, 17 + i * 2)); + [0, 1000, -1000].forEach((dy, i) => b.writeInt16BE(dy, 23 + i * 2)); + }); +} + +/** Glyph 1 placed at (10, 20) at half size. */ +function scaledComposite() { + return table(20, (b) => { + b.writeInt16BE(-1, 0); + b.writeUInt16BE(0x000b, 10); // words, xy values, one scale, last component + b.writeUInt16BE(1, 12); + b.writeInt16BE(10, 14); b.writeInt16BE(20, 16); + b.writeInt16BE(8192, 18); // 0.5 in F2Dot14 + }); +} + +// --- a minimal CFF table + +function cffIndex(items) { + if (!items.length) return Buffer.from([0, 0]); + const head = Buffer.alloc(3 + (items.length + 1) * 4); + head.writeUInt16BE(items.length, 0); + head[2] = 4; + let offset = 1; + items.forEach((item, i) => { head.writeUInt32BE(offset, 3 + i * 4); offset += item.length; }); + head.writeUInt32BE(offset, 3 + items.length * 4); + return Buffer.concat([head, ...items]); +} + +const OPS = { hlineto: 6, rmoveto: 21, endchar: 14, callgsubr: 29, return: 11, hvcurveto: 31 }; +function charString(...parts) { + const bytes = []; + for (const p of parts) { + if (typeof p === 'string') bytes.push(OPS[p]); + else if (p >= -107 && p <= 107) bytes.push(p + 139); + else bytes.push(28, (p >> 8) & 0xff, p & 0xff); + } + return Buffer.from(bytes); +} + +function cffTable(charStrings, globalSubrs) { + const header = Buffer.from([1, 0, 4, 4]); + const name = cffIndex([Buffer.from('TestCFF')]); + const strings = cffIndex([]); + const gsubrs = cffIndex(globalSubrs); + const topSize = 3 + 2 * 4 + 6; + const charStringsAt = header.length + name.length + topSize + strings.length + gsubrs.length; + const top = Buffer.from([29, 0, 0, 0, 0, 17]); + top.writeInt32BE(charStringsAt, 1); + return Buffer.concat([header, name, cffIndex([top]), strings, gsubrs, cffIndex(charStrings)]); +} + +function cffFont() { + return makeFont({ + chars: '中文', + cff: cffTable([ + charString('endchar'), + // A width (600) before the first move, then a box drawn with alternating lines. + charString(600, 100, 0, 'rmoveto', 400, 700, -400, 'hlineto', 'endchar'), + charString(0, 0, 'rmoveto', -107, 'callgsubr', 'endchar'), + ], [charString(100, 50, 50, 100, 'hvcurveto', 'return')]), + }); +} + +// --- test fallback fonts + +function fakeFallbacks(buf, asked = []) { + return (prefer) => { + asked.push(prefer); + return new FallbackFonts([{ regular: { file: 'test.ttf', index: 0 }, bold: null }], { load: () => new TrueTypeFont(buf, 0, { cff: true }) }); + }; +} + +function withFonts(t, factory) { + text.setFallbackFonts(factory); + t.after(() => text.setFallbackFonts()); +} + +function inkBox(img, color) { + let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1, count = 0; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const p = (y * img.width + x) * 4; + if (img.data[p] === color[0] && img.data[p + 1] === color[1] && img.data[p + 2] === color[2]) { + count++; + minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); + } + } + } + return { count, w: maxX - minX + 1, h: maxY - minY + 1 }; +} + +// --- outlines + +test('TrueType outlines: on- and off-curve points become lines and quadratic curves; composites are moved and scaled', () => { + const font = new TrueTypeFont(makeFont({ chars: 'AB', glyphs: { 1: curveGlyph(), 2: scaledComposite() } })); + assert.deepEqual(font.outline(1), [['M', 0, 0], ['Q', 500, 1000, 1000, 0], ['L', 0, 0]]); + assert.deepEqual(font.outline(2), [['M', 10, 20], ['Q', 260, 520, 510, 20], ['L', 10, 20]]); + assert.deepEqual(font.outline(0)[0], ['M', 0, 0], 'the plain box glyph'); + assert.deepEqual(font.outline(99), [], 'glyph ids past the end draw nothing'); +}); + +test('CFF outlines: read only when asked for, with widths skipped, alternating lines, subroutines and curves', () => { + const buf = cffFont(); + assert.throws(() => new TrueTypeFont(buf), /CFF/, 'PDF embedding still rejects CFF fonts'); + const font = new TrueTypeFont(buf, 0, { cff: true }); + assert.equal(font.glyphFor('文'.codePointAt(0)), 2); + assert.deepEqual(font.outline(1), [['M', 100, 0], ['L', 500, 0], ['L', 500, 700], ['L', 100, 700]]); + assert.deepEqual(font.outline(2), [['M', 0, 0], ['C', 100, 0, 150, 50, 150, 150]]); + assert.throws(() => font.subset([1]), /cannot be subset/); +}); + +test('Linux font lists keep CFF fonts (Noto Sans CJK) only for drawing', () => { + const run = () => '/f/DejaVuSans.ttf\t0\tTrueType\n/f/NotoSansCJK-Regular.ttc\t2\tCFF\n'; + const files = (cff) => systemFontCandidates({ text: '打开', platform: 'linux', run, cff }) + .filter((pair) => pair.regular).map((pair) => `${pair.regular.file}#${pair.regular.index}`); + assert.deepEqual(files(false), ['/f/DejaVuSans.ttf#0']); + assert.deepEqual(files(true), ['/f/DejaVuSans.ttf#0', '/f/NotoSansCJK-Regular.ttc#2']); +}); + +// --- drawing + +test('text boxes draw Chinese from an installed font at the annotation size instead of "?"', (t) => { + const red = [229, 72, 77, 255]; + const draw = () => raster.renderAnnotations(raster.createImage(400, 200), [ + { type: 'text', x: 0.1, y: 0.1, w: 0.5, h: 0.2, text: '中', style: { stroke: '#E5484D', fontSize: 0.1 } }, + ]); + text.setFallbackFonts(() => null); + const missing = inkBox(draw(), red); + withFonts(t, fakeFallbacks(makeFont({ chars: CHINESE }))); + const drawn = inkBox(draw(), red); + // The test glyph is a 1000x700 box on a 1000-unit em: 20x14 px at 20 px. + assert.ok(Math.abs(drawn.w - 20) <= 1 && Math.abs(drawn.h - 14) <= 1, `box ${drawn.w}x${drawn.h}`); + assert.ok(drawn.count >= 18 * 12, `solid fill (${drawn.count} px)`); + assert.ok(missing.count < drawn.count / 2, 'without a font it is a "?"'); +}); + +test('tooltips center and fit Chinese text, and CFF fonts draw too', (t) => { + withFonts(t, fakeFallbacks(cffFont())); + const yellow = [255, 238, 0, 255]; + const out = raster.renderAnnotations(raster.createImage(400, 200), [ + { type: 'tooltip', x: 0.1, y: 0.1, w: 0.6, h: 0.3, text: '中', style: { fontSize: 0.1, textColor: '#FFEE00' } }, + ]); + const ink = inkBox(out, yellow); + // CFF glyph 1 is a 400x700 box: 8x14 px at 20 px. + assert.ok(Math.abs(ink.w - 8) <= 1 && Math.abs(ink.h - 14) <= 1, `box ${ink.w}x${ink.h}`); +}); + +test('measuring, wrapping and fitting use installed-font widths and break between Chinese characters', (t) => { + const asked = []; + withFonts(t, fakeFallbacks(makeFont({ chars: CHINESE + JAPANESE }), asked)); + assert.equal(text.lineWidth('中中', 20), 40); + assert.equal(text.lineWidth('中️‍', 20), 20, 'variation selectors and joiners take no space'); + const lines = text.wrapText('打开设置然后重置密码', 20, 100); + assert.deepEqual(lines, ['打开设置然', '后重置密码']); + const fitted = text.fitText('打开设置然后重置密码', 20, 100); + assert.ok(fitted.endsWith('…') && text.lineWidth(fitted, 20) <= 100); + text.lineWidth(JAPANESE, 20); + assert.deepEqual([...new Set(asked)], ['zh', 'ja'], 'Japanese text asks for Japanese fonts first'); +}); + +test('fitting never splits a character outside the Basic Multilingual Plane', (t) => { + withFonts(t, () => null); + const fitted = text.fitText('😀😀😀😀😀😀😀😀😀😀', 20, 50); + assert.ok(fitted.endsWith('…')); + assert.doesNotMatch(fitted, /[\ud800-\udbff](?![\udc00-\udfff])|(? { + const fonts = new FallbackFonts(() => systemFontCandidates({ cff: true }), { load: (face) => new TrueTypeFont(require('node:fs').readFileSync(face.file), face.index, { cff: true }) }); + if (!fonts.fontFor('中'.codePointAt(0), true)) return t.skip('no installed font has Chinese'); + const draw = (str) => raster.renderAnnotations(raster.createImage(300, 120), [ + { type: 'text', x: 0.05, y: 0.1, w: 0.9, h: 0.5, text: str, style: { stroke: '#000000', fontSize: 0.25 } }, + ]); + text.setFallbackFonts(() => null); + const unknown = draw('中文'); + text.setFallbackFonts(); + t.after(() => text.setFallbackFonts()); + const zh = draw('中文'); + const differing = zh.data.filter((v, i) => v !== unknown.data[i]).length; + assert.ok(differing > 500, `Chinese glyphs differ from "??" (${differing} bytes)`); + assert.notDeepEqual(draw('中').data, draw('文').data, 'different characters draw differently'); +}); diff --git a/tests/unit/font-fixtures.js b/tests/unit/font-fixtures.js new file mode 100644 index 00000000..176f19fb --- /dev/null +++ b/tests/unit/font-fixtures.js @@ -0,0 +1,129 @@ +'use strict'; + +// A tiny TrueType (or CFF) font, built here so the tests need no installed fonts. + +function table(size, write) { + const buf = Buffer.alloc(size); + write(buf); + return buf; +} + +/** A square glyph `width` units wide: one contour of four on-curve points. */ +function boxGlyph(width) { + return table(36, (b) => { + b.writeInt16BE(1, 0); b.writeInt16BE(0, 2); b.writeInt16BE(0, 4); b.writeInt16BE(width, 6); b.writeInt16BE(700, 8); + b.writeUInt16BE(3, 10); // last point of the contour + b.writeUInt16BE(0, 12); // no instructions + b.fill(1, 14, 18); // on-curve, 16-bit coordinate deltas + [0, width, 0, -width].forEach((dx, i) => b.writeInt16BE(dx, 18 + i * 2)); + [0, 0, 700, 0].forEach((dy, i) => b.writeInt16BE(dy, 26 + i * 2)); + }); +} + +/** A composite glyph drawn from `part` (so subsetting must keep `part`). */ +function compositeGlyph(part) { + return table(20, (b) => { + b.writeInt16BE(-1, 0); b.writeInt16BE(0, 2); b.writeInt16BE(0, 4); b.writeInt16BE(500, 6); b.writeInt16BE(700, 8); + b.writeUInt16BE(0x0003, 10); // ARG_1_AND_2_ARE_WORDS | ARGS_ARE_XY_VALUES, last component + b.writeUInt16BE(part, 12); + b.writeInt16BE(0, 14); b.writeInt16BE(0, 16); + }); +} + +function cmapFormat4(map) { + const cps = [...map.keys()].filter((cp) => cp <= 0xffff).sort((a, b) => a - b); + const segs = cps.length + 1; + return table(16 + segs * 8, (b) => { + b.writeUInt16BE(4, 0); b.writeUInt16BE(16 + segs * 8, 2); b.writeUInt16BE(segs * 2, 6); + const ends = 14, starts = ends + segs * 2 + 2, deltas = starts + segs * 2; // idRangeOffsets stay 0 + cps.forEach((cp, i) => { + b.writeUInt16BE(cp, ends + i * 2); b.writeUInt16BE(cp, starts + i * 2); + b.writeInt16BE(((map.get(cp) - cp) << 16) >> 16, deltas + i * 2); + }); + b.writeUInt16BE(0xffff, ends + cps.length * 2); b.writeUInt16BE(0xffff, starts + cps.length * 2); + b.writeInt16BE(1, deltas + cps.length * 2); + }); +} + +function cmapFormat12(map) { + const cps = [...map.keys()].sort((a, b) => a - b); + return table(16 + cps.length * 12, (b) => { + b.writeUInt16BE(12, 0); b.writeUInt32BE(16 + cps.length * 12, 4); b.writeUInt32BE(cps.length, 12); + cps.forEach((cp, i) => { b.writeUInt32BE(cp, 16 + i * 12); b.writeUInt32BE(cp, 20 + i * 12); b.writeUInt32BE(map.get(cp), 24 + i * 12); }); + }); +} + +/** + * A TrueType font with a box glyph for each character in `chars` (glyph ids + * from 1), the given advance widths, and an optional composite glyph. + * `glyphs` replaces individual glyph records by id; `cff` (a 'CFF ' table) + * makes it an OpenType CFF font instead, with no glyf/loca tables. + */ +function makeFont({ chars, width = 1000, unitsPerEm = 1000, name = 'TestSans', format12 = true, composite = false, glyphs: custom = {}, cff = null }) { + const map = new Map([...chars].map((ch, i) => [ch.codePointAt(0), i + 1])); + const glyphs = [boxGlyph(500), ...[...chars].map(() => boxGlyph(width))]; + if (composite) glyphs.push(compositeGlyph(1)); + for (const [id, record] of Object.entries(custom)) glyphs[Number(id)] = record; + const loca = Buffer.alloc((glyphs.length + 1) * 4); + let at = 0; + glyphs.forEach((g, i) => { loca.writeUInt32BE(at, i * 4); at += g.length; }); + loca.writeUInt32BE(at, glyphs.length * 4); + const sub4 = cmapFormat4(map); + const sub12 = format12 ? cmapFormat12(map) : null; + const cmap = Buffer.concat([table(4 + (sub12 ? 16 : 8), (b) => { + b.writeUInt16BE(sub12 ? 2 : 1, 2); + b.writeUInt16BE(3, 4); b.writeUInt16BE(1, 6); b.writeUInt32BE(4 + (sub12 ? 16 : 8), 8); + if (sub12) { b.writeUInt16BE(3, 12); b.writeUInt16BE(10, 14); b.writeUInt32BE(4 + 16 + sub4.length, 16); } + }), sub4, ...(sub12 ? [sub12] : [])]); + const nameUtf16 = Buffer.from(name, 'utf16le').swap16(); + const tables = { + cmap, + glyf: Buffer.concat(glyphs), + head: table(54, (b) => { b.writeUInt32BE(0x00010000, 0); b.writeUInt16BE(unitsPerEm, 18); b.writeInt16BE(700, 42); b.writeInt16BE(1000, 40); b.writeInt16BE(1, 50); }), + hhea: table(36, (b) => { b.writeInt16BE(800, 4); b.writeInt16BE(-200, 6); b.writeUInt16BE(glyphs.length, 34); }), + hmtx: Buffer.concat(glyphs.map((_, i) => table(4, (b) => b.writeUInt16BE(i ? width : 500, 0)))), + loca, + maxp: table(6, (b) => { b.writeUInt32BE(0x00005000, 0); b.writeUInt16BE(glyphs.length, 4); }), + ...(cff ? { 'CFF ': cff } : {}), + name: Buffer.concat([table(18, (b) => { + b.writeUInt16BE(1, 2); b.writeUInt16BE(18, 4); + b.writeUInt16BE(3, 6); b.writeUInt16BE(1, 8); b.writeUInt16BE(0x409, 10); b.writeUInt16BE(6, 12); b.writeUInt16BE(nameUtf16.length, 14); + }), nameUtf16]), + }; + if (cff) { delete tables.glyf; delete tables.loca; } + const tags = Object.keys(tables).sort(); + const header = Buffer.alloc(12 + tags.length * 16); + header.writeUInt32BE(cff ? 0x4f54544f : 0x00010000, 0); + header.writeUInt16BE(tags.length, 4); + let offset = header.length; + const bodies = []; + tags.forEach((tag, i) => { + const body = tables[tag]; + header.write(tag, 12 + i * 16, 'latin1'); + header.writeUInt32BE(offset, 12 + i * 16 + 8); + header.writeUInt32BE(body.length, 12 + i * 16 + 12); + const padded = (body.length + 3) & ~3; + bodies.push(body, Buffer.alloc(padded - body.length)); + offset += padded; + }); + return Buffer.concat([header, ...bodies]); +} + +/** Two fonts in one .ttc file. */ +function makeCollection(first, second) { + const head = Buffer.alloc(20); + head.write('ttcf', 0, 'latin1'); + head.writeUInt32BE(0x00010000, 4); + head.writeUInt32BE(2, 8); + head.writeUInt32BE(20, 12); + head.writeUInt32BE(20 + first.length, 16); + // Table offsets in a collection count from the start of the file. + const shift = (font, by) => { + const copy = Buffer.from(font); + for (let i = 0; i < copy.readUInt16BE(4); i++) copy.writeUInt32BE(copy.readUInt32BE(12 + i * 16 + 8) + by, 12 + i * 16 + 8); + return copy; + }; + return Buffer.concat([head, shift(first, 20), shift(second, 20 + first.length)]); +} + +module.exports = { table, boxGlyph, compositeGlyph, makeFont, makeCollection }; diff --git a/tests/unit/pdf-unicode.test.js b/tests/unit/pdf-unicode.test.js index 81ad196f..a5a69599 100644 --- a/tests/unit/pdf-unicode.test.js +++ b/tests/unit/pdf-unicode.test.js @@ -13,127 +13,8 @@ const { buildRenderAst } = require('../../core/renderast'); const { GuideStore } = require('../../core/store'); const { fileNameSlug } = require('../../core/util'); const { makeTmpDir, rmrf } = require('./helpers'); +const { makeFont, makeCollection } = require('./font-fixtures'); -// --- A tiny TrueType font, built here so the tests need no installed fonts. - -function table(size, write) { - const buf = Buffer.alloc(size); - write(buf); - return buf; -} - -/** A square glyph `width` units wide: one contour of four on-curve points. */ -function boxGlyph(width) { - return table(36, (b) => { - b.writeInt16BE(1, 0); b.writeInt16BE(0, 2); b.writeInt16BE(0, 4); b.writeInt16BE(width, 6); b.writeInt16BE(700, 8); - b.writeUInt16BE(3, 10); // last point of the contour - b.writeUInt16BE(0, 12); // no instructions - b.fill(1, 14, 18); // on-curve, 16-bit coordinate deltas - [0, width, 0, -width].forEach((dx, i) => b.writeInt16BE(dx, 18 + i * 2)); - [0, 0, 700, 0].forEach((dy, i) => b.writeInt16BE(dy, 26 + i * 2)); - }); -} - -/** A composite glyph drawn from `part` (so subsetting must keep `part`). */ -function compositeGlyph(part) { - return table(20, (b) => { - b.writeInt16BE(-1, 0); b.writeInt16BE(0, 2); b.writeInt16BE(0, 4); b.writeInt16BE(500, 6); b.writeInt16BE(700, 8); - b.writeUInt16BE(0x0003, 10); // ARG_1_AND_2_ARE_WORDS | ARGS_ARE_XY_VALUES, last component - b.writeUInt16BE(part, 12); - b.writeInt16BE(0, 14); b.writeInt16BE(0, 16); - }); -} - -function cmapFormat4(map) { - const cps = [...map.keys()].filter((cp) => cp <= 0xffff).sort((a, b) => a - b); - const segs = cps.length + 1; - return table(16 + segs * 8, (b) => { - b.writeUInt16BE(4, 0); b.writeUInt16BE(16 + segs * 8, 2); b.writeUInt16BE(segs * 2, 6); - const ends = 14, starts = ends + segs * 2 + 2, deltas = starts + segs * 2; // idRangeOffsets stay 0 - cps.forEach((cp, i) => { - b.writeUInt16BE(cp, ends + i * 2); b.writeUInt16BE(cp, starts + i * 2); - b.writeInt16BE(((map.get(cp) - cp) << 16) >> 16, deltas + i * 2); - }); - b.writeUInt16BE(0xffff, ends + cps.length * 2); b.writeUInt16BE(0xffff, starts + cps.length * 2); - b.writeInt16BE(1, deltas + cps.length * 2); - }); -} - -function cmapFormat12(map) { - const cps = [...map.keys()].sort((a, b) => a - b); - return table(16 + cps.length * 12, (b) => { - b.writeUInt16BE(12, 0); b.writeUInt32BE(16 + cps.length * 12, 4); b.writeUInt32BE(cps.length, 12); - cps.forEach((cp, i) => { b.writeUInt32BE(cp, 16 + i * 12); b.writeUInt32BE(cp, 20 + i * 12); b.writeUInt32BE(map.get(cp), 24 + i * 12); }); - }); -} - -/** - * A TrueType font with a box glyph for each character in `chars` (glyph ids - * from 1), the given advance widths, and an optional composite glyph. - */ -function makeFont({ chars, width = 1000, unitsPerEm = 1000, name = 'TestSans', format12 = true, composite = false }) { - const map = new Map([...chars].map((ch, i) => [ch.codePointAt(0), i + 1])); - const glyphs = [boxGlyph(500), ...[...chars].map(() => boxGlyph(width))]; - if (composite) glyphs.push(compositeGlyph(1)); - const loca = Buffer.alloc((glyphs.length + 1) * 4); - let at = 0; - glyphs.forEach((g, i) => { loca.writeUInt32BE(at, i * 4); at += g.length; }); - loca.writeUInt32BE(at, glyphs.length * 4); - const sub4 = cmapFormat4(map); - const sub12 = format12 ? cmapFormat12(map) : null; - const cmap = Buffer.concat([table(4 + (sub12 ? 16 : 8), (b) => { - b.writeUInt16BE(sub12 ? 2 : 1, 2); - b.writeUInt16BE(3, 4); b.writeUInt16BE(1, 6); b.writeUInt32BE(4 + (sub12 ? 16 : 8), 8); - if (sub12) { b.writeUInt16BE(3, 12); b.writeUInt16BE(10, 14); b.writeUInt32BE(4 + 16 + sub4.length, 16); } - }), sub4, ...(sub12 ? [sub12] : [])]); - const nameUtf16 = Buffer.from(name, 'utf16le').swap16(); - const tables = { - cmap, - glyf: Buffer.concat(glyphs), - head: table(54, (b) => { b.writeUInt32BE(0x00010000, 0); b.writeUInt16BE(unitsPerEm, 18); b.writeInt16BE(700, 42); b.writeInt16BE(1000, 40); b.writeInt16BE(1, 50); }), - hhea: table(36, (b) => { b.writeInt16BE(800, 4); b.writeInt16BE(-200, 6); b.writeUInt16BE(glyphs.length, 34); }), - hmtx: Buffer.concat(glyphs.map((_, i) => table(4, (b) => b.writeUInt16BE(i ? width : 500, 0)))), - loca, - maxp: table(6, (b) => { b.writeUInt32BE(0x00005000, 0); b.writeUInt16BE(glyphs.length, 4); }), - name: Buffer.concat([table(18, (b) => { - b.writeUInt16BE(1, 2); b.writeUInt16BE(18, 4); - b.writeUInt16BE(3, 6); b.writeUInt16BE(1, 8); b.writeUInt16BE(0x409, 10); b.writeUInt16BE(6, 12); b.writeUInt16BE(nameUtf16.length, 14); - }), nameUtf16]), - }; - const tags = Object.keys(tables).sort(); - const header = Buffer.alloc(12 + tags.length * 16); - header.writeUInt32BE(0x00010000, 0); - header.writeUInt16BE(tags.length, 4); - let offset = header.length; - const bodies = []; - tags.forEach((tag, i) => { - const body = tables[tag]; - header.write(tag, 12 + i * 16, 'latin1'); - header.writeUInt32BE(offset, 12 + i * 16 + 8); - header.writeUInt32BE(body.length, 12 + i * 16 + 12); - const padded = (body.length + 3) & ~3; - bodies.push(body, Buffer.alloc(padded - body.length)); - offset += padded; - }); - return Buffer.concat([header, ...bodies]); -} - -/** Two fonts in one .ttc file. */ -function makeCollection(first, second) { - const head = Buffer.alloc(20); - head.write('ttcf', 0, 'latin1'); - head.writeUInt32BE(0x00010000, 4); - head.writeUInt32BE(2, 8); - head.writeUInt32BE(20, 12); - head.writeUInt32BE(20 + first.length, 16); - // Table offsets in a collection count from the start of the file. - const shift = (font, by) => { - const copy = Buffer.from(font); - for (let i = 0; i < copy.readUInt16BE(4); i++) copy.writeUInt32BE(copy.readUInt32BE(12 + i * 16 + 8) + by, 12 + i * 16 + 8); - return copy; - }; - return Buffer.concat([head, shift(first, 20), shift(second, 20 + first.length)]); -} // --- Reading a PDF back: the text a viewer would copy. From a73814cc8d3a6abbb20ed3153238e9fbb6161205 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 9 Nov 2026 10:44:13 -0600 Subject: [PATCH 02/13] Document installed-font fallback for rasterized text --- docs/ARCHITECTURE.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 192c8f75..8e8179aa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -134,8 +134,8 @@ Any other character is drawn from an installed TrueType font: `core/pdf-fonts.js` lists candidates (Windows font folder by script, with Chinese, Japanese or Korean first depending on the guide's text; fontconfig's `fc-match -s` on Linux) and loads each only when an earlier one lacks a -character. `core/ttf.js` reads `.ttf`/`.ttc` files (glyf outlines only; CFF -fonts are skipped) and writes a subset that keeps glyph ids, which +character. `core/ttf.js` reads `.ttf`/`.ttc` files (glyf outlines only for +PDFs; CFF fonts are skipped there) and writes a subset that keeps glyph ids, which `core/pdf.js` embeds as a Type0/CIDFontType2 font with `/CIDToGIDMap /Identity`, real widths, and a ToUnicode CMap so viewers can copy and search the text. Lines may break between Chinese, Japanese and Korean characters @@ -149,7 +149,12 @@ PNG pixels decoded by `core/png.js`. Before building the AST, export workers prepare referenced WebP screenshots as PNG through `core/image-format.js`; preview preparation follows guide order and the preview step limit. Text in rasterized images (annotation labels, GIF captions) is anti-aliased from the glyph atlases in `core/fonts/` -(rendered from Lato by `scripts/make-font-atlas.js`; SIL Open Font License). Every exporter accepts a template object +(rendered from Lato by `scripts/make-font-atlas.js`; SIL Open Font License). +Characters outside the atlas (Chinese, Cyrillic, Thai, ...) are filled from +installed-font outlines in `core/text-raster.js`, using the same +`core/pdf-fonts.js` candidates plus CFF fonts such as Noto Sans CJK +(`core/cff.js` reads their charstrings); right-to-left order and shaping are +not handled there either. Every exporter accepts a template object (per-format settings persisted under `settings/templates/`, shareable as `.sfglt` zip files). From 6fb674712ffef03055c3e9124e63468bdce5e6f6 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 9 Nov 2026 10:55:43 -0600 Subject: [PATCH 03/13] Add an export translator and check exporter text for translations core/export-text.js gives exporters the interface-language translator. tc(context, text) tells apart English text used in two senses, and the translation check now scans exporters/, core/renderast.js and core/pages-site.js and reads \n in messages. --- core/export-text.js | 34 ++++++++++++++++++++++++++++++++++ core/i18n.js | 19 +++++++++++++------ scripts/check-translations.js | 14 +++++++++----- 3 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 core/export-text.js diff --git a/core/export-text.js b/core/export-text.js new file mode 100644 index 00000000..810b6c06 --- /dev/null +++ b/core/export-text.js @@ -0,0 +1,34 @@ +'use strict'; + +const { LANGUAGES, createTranslator } = require('./i18n'); +const LOCALES = require('./locales'); + +/** + * The text StepForge itself writes into exported guides ("Contents", "Last + * verified: …", alt text, page numbers), in the interface language the export + * was made in (Settings → General → Language). The render AST carries that + * language (`ast.language`, see core/renderast.js); a guide's own text is + * left as written. + */ + +const translators = new Map(); + +/** { t, tn, tc, language } for a language code; English for anything unknown. */ +function exportTranslator(language) { + const key = String(language || 'en'); + if (!translators.has(key)) translators.set(key, createTranslator(key, LOCALES)); + return translators.get(key); +} + +/** The translator for a render AST's export language. */ +function exportText(ast) { + return exportTranslator(ast && ast.language); +} + +/** The BCP 47 tag for a document's language attribute: "en-US", "zh-CN", "ja-JP". */ +function languageTag(language) { + const found = LANGUAGES.find((l) => l.code === language); + return found ? found.store : 'en-US'; +} + +module.exports = { exportTranslator, exportText, languageTag }; diff --git a/core/i18n.js b/core/i18n.js index 04af1bd6..07690d69 100644 --- a/core/i18n.js +++ b/core/i18n.js @@ -10,8 +10,13 @@ * a number uses tn(count, singular, plural), and its catalog entry is an * object with one string per plural category of that language (Intl.PluralRules). * - * The main process and the sandboxed renderer (which loads this file and the - * catalogs with script tags) share it. Exported guides stay in English. + * The same English text can mean two things ("Contents" as an option group + * and as the heading over a table of contents): tc(context, text) looks it up + * as "context|text" instead, and shows `text` when the catalog lacks that key. + * + * The main process, the export worker (core/export-text.js: the text + * StepForge writes into exported guides) and the sandboxed renderer (which + * loads this file and the catalogs with script tags) share it. */ (function attachI18n(root, factory) { @@ -67,7 +72,7 @@ } /** - * { t, tn, language } for `language`. `catalogs` maps a language code to + * { t, tn, tc, language } for `language`. `catalogs` maps a language code to * its messages. */ function createTranslator(language, catalogs = {}) { @@ -78,10 +83,12 @@ plurals = plurals || new Intl.PluralRules(code); return plurals.select(count); }; - const t = (text, params) => { - const found = messages[text]; + const lookup = (key, text, params) => { + const found = messages[key]; return fill(typeof found === 'string' && found ? found : text, params); }; + const t = (text, params) => lookup(text, text, params); + const tc = (context, text, params) => lookup(`${context}|${text}`, text, params); // `plural` is the key; `{count}` is filled in unless params set it. const tn = (count, singular, plural, params) => { const values = { count, ...params }; @@ -92,7 +99,7 @@ } return fill(count === 1 ? singular : plural, values); }; - return { t, tn, language: code }; + return { t, tn, tc, language: code }; } return { LANGUAGES, createTranslator, matchLocale, resolveLanguage }; diff --git a/scripts/check-translations.js b/scripts/check-translations.js index d3126d07..ba7065a8 100644 --- a/scripts/check-translations.js +++ b/scripts/check-translations.js @@ -18,19 +18,23 @@ const { LANGUAGES } = require('../core/i18n'); const LOCALES = require('../core/locales'); const ROOT = path.resolve(__dirname, '..'); -const SOURCES = ['app/main.js', 'app/platform/linux/portal-errors.js', 'app/platform/linux/portal-backend.js', ...fs.readdirSync(path.join(ROOT, 'app/renderer')) - .filter((name) => name.endsWith('.js')).map((name) => `app/renderer/${name}`)]; +const jsFiles = (dir) => fs.readdirSync(path.join(ROOT, dir)).filter((name) => name.endsWith('.js')).map((name) => `${dir}/${name}`); +// The interface, plus the text StepForge writes into exported guides (core/export-text.js). +const SOURCES = ['app/main.js', 'app/platform/linux/portal-errors.js', 'app/platform/linux/portal-backend.js', ...jsFiles('app/renderer'), + ...jsFiles('exporters'), 'core/renderast.js', 'core/pages-site.js']; const LITERAL = String.raw`'((?:[^'\\\n]|\\.)*)'`; const CALLS = [ { re: new RegExp(String.raw`(? text.replace(/\\(.)/g, '$1'); +const unescape = (text) => text.replace(/\\(.)/g, (whole, ch) => (ch === 'n' ? '\n' : ch)); const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(','); /** Map of English text → { plural, singular?, where }. */ @@ -38,10 +42,10 @@ function findMessages() { const found = new Map(); for (const file of SOURCES) { const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); - for (const { re, plural } of CALLS) { + for (const { re, plural, context } of CALLS) { for (const match of source.matchAll(re)) { const line = source.slice(0, match.index).split('\n').length; - const key = unescape(plural ? match[2] : match[1]); + const key = context ? `${unescape(match[1])}|${unescape(match[2])}` : unescape(plural ? match[2] : match[1]); if (!found.has(key)) found.set(key, { plural, singular: plural ? unescape(match[1]) : null, where: `${file}:${line}` }); } } From 5cdf1cabf0c0fdf49f5c04a94e1bc289dcbb1609 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 9 Nov 2026 10:55:47 -0600 Subject: [PATCH 04/13] Write export text in the interface language (#188) Exports used English for everything StepForge adds, whatever language was chosen: "Last verified", "Contents", page numbers, alt text, callout names, step counts, the HTML checklist and copy buttons, the Confluence import instructions and the GitHub Pages front page. The export worker now gets the interface language (Settings > General > Language) with every job, the render AST carries it, and each exporter writes its labels through core/export-text.js. English output stays the same apart from "1 step" on a one-step PDF cover (was "1 steps") and a correctly sized underline in HOW-TO-IMPORT.txt. GitHub alert names stay English, since GitHub only reads those. --- app/export-runner.js | 7 +- app/github-pages.js | 5 +- app/main.js | 18 ++-- core/branding-sample.js | 4 +- core/export-worker.js | 4 +- core/locales/es.js | 61 +++++++++++ core/locales/fr.js | 61 +++++++++++ core/locales/ja.js | 61 +++++++++++ core/locales/ru.js | 61 +++++++++++ core/locales/zh-CN.js | 61 +++++++++++ core/pages-site.js | 29 ++++-- core/renderast.js | 56 ++++++---- exporters/clipboard.js | 49 +++++---- exporters/common.js | 49 ++++++++- exporters/confluence-word.js | 24 +++-- exporters/confluence.js | 118 ++++++++++----------- exporters/document-layout.js | 44 ++++++-- exporters/docx.js | 39 ++++--- exporters/gif.js | 15 +-- exporters/html.js | 87 ++++++++++------ exporters/image-bundle.js | 8 +- exporters/markdown-guide.js | 48 +++++---- exporters/markdown.js | 1 - exporters/pdf.js | 31 +++--- exporters/pptx.js | 56 +++++----- exporters/wikijs.js | 1 - tests/unit/export-language.test.js | 160 +++++++++++++++++++++++++++++ tests/unit/pages-site.test.js | 2 +- 28 files changed, 891 insertions(+), 269 deletions(-) create mode 100644 tests/unit/export-language.test.js diff --git a/app/export-runner.js b/app/export-runner.js index f09a94ce..c5dd4704 100644 --- a/app/export-runner.js +++ b/app/export-runner.js @@ -9,9 +9,10 @@ const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js'); * Run an export, quality check or title job in a forked helper process, so building the render AST * and rendering the output (e.g. a multi-step PDF) never blocks the main * process — and therefore never freezes the editor window — no matter how - * large the guide is. + * large the guide is. `language` is the interface language the text + * StepForge adds to the export is written in (core/export-text.js). */ -function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding = null, task = 'export' }) { +function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding = null, language = 'en', task = 'export' }) { return new Promise((resolve, reject) => { const worker = fork(WORKER_PATH, [], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, @@ -35,7 +36,7 @@ function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`)); }); - worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, task }); + worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, language, task }); }); } diff --git a/app/github-pages.js b/app/github-pages.js index 9aa4ebe0..ee3f654c 100644 --- a/app/github-pages.js +++ b/app/github-pages.js @@ -90,6 +90,8 @@ class GitHubPages { wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), onStatus = () => {}, copyText = null, + // The interface language, for the site's front page. + language = () => 'en', }) { this.file = path.join(directory, 'github.credentials'); this.clientId = clientId; @@ -102,6 +104,7 @@ class GitHubPages { this.wait = wait; this.onStatus = onStatus; this.copyText = copyText; + this.language = language; this.credentials = null; this.loadError = null; @@ -729,7 +732,7 @@ class GitHubPages { const unchanged = current.head && !publish && !extend && !plan.removed.length && !remove && current.entries.size === tree.length + 2 && current.entries.has(site.ROOT_INDEX_PATH); if (unchanged) return { ...plan, changed: false }; - tree.push({ path: site.ROOT_INDEX_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.ROOT_INDEX)) }); + tree.push({ path: site.ROOT_INDEX_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.rootIndex(this.language()))) }); tree.push({ path: site.MANIFEST_PATH, mode: '100644', type: 'blob', sha: await this.blob(Buffer.from(site.serializeManifest(plan.manifest))) }); const createdTree = await this.api('POST', this.repoPath('/git/trees'), { tree }); const message = publish ? 'Publish a StepForge guide' : extend ? 'Extend a shared StepForge guide' : remove ? 'Remove a StepForge guide' : 'Update shared StepForge guides'; diff --git a/app/main.js b/app/main.js index 152d5a98..6aef10c7 100644 --- a/app/main.js +++ b/app/main.js @@ -927,7 +927,7 @@ function setupIpc() { steps: orderedSteps(guideId), }), { validate: (a) => c.id(a.guideId) }); // What to fix before exporting or publishing (core/quality.js). - h('guide:quality', ({ guideId }) => runExportInWorker({ dataDir: store.root, guideId, task: 'quality', + h('guide:quality', ({ guideId }) => runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, task: 'quality', globals: settings.getGlobalPlaceholders(), reviewDays: exportReviewDays(), }), { validate: (a) => c.id(a.guideId) }); // An AI agent may have moved the guide to the Trash meanwhile; a late save @@ -1312,7 +1312,7 @@ function setupIpc() { progress({ stage: 'check' }); await redactor.checkGuide(guideId, { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }) }); progress({ stage: 'export' }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'confluence', + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'confluence', options: { apiFiles: true, includeImages: true, toc: true }, outDir, globals: settings.getGlobalPlaceholders(), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), branding: exportBranding() }); return await confluence.publish({ guideId, space, parent, parentPicked, replace, onProgress: progress, @@ -1334,7 +1334,7 @@ function setupIpc() { h('wikijs:defaults', async ({ guideId }) => { if (!store.guideExists(guideId)) throw new Error(t('Guide not found.')); // The title as exported, with placeholders filled in, names the page. - const title = await runExportInWorker({ dataDir: store.root, guideId, task: 'title', globals: settings.getGlobalPlaceholders() }); + const title = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, task: 'title', globals: settings.getGlobalPlaceholders() }); return wikijs.defaultsFor(guideId, title); }, { validate: (a) => c.id(a.guideId) }); h('wikijs:publish', async ({ guideId, path: pagePath, title, locale = 'en', replace = false }) => { @@ -1354,7 +1354,7 @@ function setupIpc() { await redactor.checkGuide(guideId, { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }) }); progress({ stage: 'export' }); const assetFolder = `/${target}`; - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'wikijs', + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'wikijs', options: { includeImages: true, toc: false, frontMatter: false, assetFolder }, outDir, globals: settings.getGlobalPlaceholders(), reviewDays: exportReviewDays(), branding: exportBranding() }); return await wikijs.publish({ guideId, path: target, title, locale: language, description: result.description, replace, onProgress: progress, @@ -1388,7 +1388,7 @@ function setupIpc() { onProgress: ({ done, total }) => progress({ stage: 'check', done, total }), }); progress({ stage: 'build' }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format: 'clipboard', options: {}, + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format: 'clipboard', options: {}, outDir: path.join(store.tempDir, `clipboard-${guideId}`), globals: settings.getGlobalPlaceholders(), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), branding: exportBranding() }); progress({ stage: 'copy' }); @@ -1413,7 +1413,7 @@ function setupIpc() { if (total) progress({ stage: 'export', detail: t('Checking screenshots for private details ({done} of {total})…', { done, total }) }); } }); progress({ stage: 'export' }); - const result = await runExportInWorker({ + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, @@ -1785,7 +1785,7 @@ function setupIpc() { chosenOutputDirs.add(dir); } settings.set(`exports.lastOutputDirs.${format}`, dir); - const result = await runExportInWorker({ + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, @@ -1838,7 +1838,7 @@ function setupIpc() { h('export:preview', async ({ guideId, format, options, brandProfileId }) => { const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`); fs.rmSync(previewDir, { recursive: true, force: true }); - const result = await runExportInWorker({ dataDir: store.root, guideId, format, outDir: previewDir, options: options || {}, + const result = await runExportInWorker({ language: uiLanguage(), dataDir: store.root, guideId, format, outDir: previewDir, options: options || {}, globals: settings.getGlobalPlaceholders(), maxSteps: settings.get('exports.previewStepCount') || 3, codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), @@ -1923,6 +1923,7 @@ function setupIpc() { dir: path.join(previewDir, 'library'), branding: profile ? branding.resolveDraft(profile) : (id ? branding.resolve(id) : null), codeStyle: exportCodeStyle(), reviewDays: exportReviewDays(), + language: uiLanguage(), }); const result = runExport(format, ast, path.join(previewDir, 'export'), {}); const target = result.previewFile || result.file; @@ -2136,6 +2137,7 @@ if (!gotLock) { openExternal: (url) => shell.openExternal(url), // The sign-in code is copied so it can be pasted on github.com. copyText: (text) => clipboard.writeText(text), + language: uiLanguage, onStatus: (status) => sendToRenderer('github:status', status) }); cloudSync = new CloudSync({ store, drive: activeCloud(), diff --git a/core/branding-sample.js b/core/branding-sample.js index 82b258a2..b64dbb8c 100644 --- a/core/branding-sample.js +++ b/core/branding-sample.js @@ -48,7 +48,7 @@ function sampleScreenshot(accent) { * Write the sample guide under `dir` and return its render AST with * `branding` (a BrandingStore.resolve() result, or null) applied. */ -function buildSampleAst({ dir, branding = null, codeStyle, reviewDays, now = new Date() } = {}) { +function buildSampleAst({ dir, branding = null, codeStyle, reviewDays, language = 'en', now = new Date() } = {}) { fs.mkdirSync(dir, { recursive: true }); const store = new GuideStore(dir); const guide = store.createGuide({ @@ -79,7 +79,7 @@ function buildSampleAst({ dir, branding = null, codeStyle, reviewDays, now = new annotations: [{ type: 'rect', x: 0.785, y: 0.75, w: 0.155, h: 0.09, style: { stroke: '#E5484D', strokeWidth: 4, fill: 'transparent' } }], }, encodePng(shot), size); - return buildRenderAst(store, guide.guideId, { now, codeStyle, reviewDays, branding }); + return buildRenderAst(store, guide.guideId, { now, codeStyle, reviewDays, branding, language }); } module.exports = { buildSampleAst, sampleScreenshot }; diff --git a/core/export-worker.js b/core/export-worker.js index c2c2e409..bee7ef48 100644 --- a/core/export-worker.js +++ b/core/export-worker.js @@ -17,12 +17,12 @@ const { stepNumberMap } = require('./step-outline'); process.on('message', async (job) => { let payload; try { - const { dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, task = 'export' } = job; + const { dataDir, guideId, format, options, outDir, globals, maxSteps, codeStyle, reviewDays, branding, language, task = 'export' } = job; if (!['export', 'quality', 'title'].includes(task)) throw new Error('Unknown render task.'); const store = new GuideStore(dataDir); // Decode stored screenshots in the worker through the shared codec. const images = task === 'export' ? await prepareExportImages(store, guideId, { maxSteps }) : null; - const ast = buildRenderAst(store, guideId, { globals, maxSteps, codeStyle, reviewDays, branding, images }); + const ast = buildRenderAst(store, guideId, { globals, maxSteps, codeStyle, reviewDays, branding, images, language }); let result; if (task === 'quality') { const steps = store.listSteps(guideId); diff --git a/core/locales/es.js b/core/locales/es.js index 4396c220..9e153595 100644 --- a/core/locales/es.js +++ b/core/locales/es.js @@ -1897,4 +1897,65 @@ "Reset zoom to 100%": "Restablecer el zoom al 100 %", "Background": "Fondo", "This flowchart has as many pictures as it can hold. Remove one, or use smaller pictures.": "Este diagrama de flujo ya tiene todas las imágenes que admite. Quita una o usa imágenes más pequeñas.", + "(Skipped)": "(Omitido)", + "(skipped)": "(omitido)", + "{label}: {title}": "{label}: {title}", + "{label}:": "{label}:", + "Step {number}: {title}": "Paso {number}: {title}", + "Go to: {links}": "Ir a: {links}", + "Root page title:": "Título de la página raíz:", + "Where to import:": "Dónde importar:", + "Import as a new page in the current space": "Importar como página nueva en el espacio actual", + "Split by heading:": "Dividir por encabezado:", + "Don't split": "No dividir", + "How to add \"{title}\" to Confluence": "Cómo añadir «{title}» a Confluence", + "Import this Word document from your browser:": "Importa este documento de Word desde el navegador:", + "No admin rights or API access needed, just permission to create pages in\nthe space. The screenshots come in with the document.": "No necesitas permisos de administrador ni acceso a la API, solo permiso para crear\npáginas en el espacio. Las capturas de pantalla se importan con el documento.", + "Confluence Cloud (your-site.atlassian.net)": "Confluence Cloud (tu-sitio.atlassian.net)", + "1. In the space, select + Create and choose Page to open a blank page.\n2. Next to Share, select More actions (...) > Templates and import.\n3. Open the Import tab and select Word document (.docx).\n4. Select \"{file}\" and choose Open.\n5. When the import finishes, select Finish. The guide opens as a draft:\n check the title and content, then Publish.": "1. En el espacio, selecciona + Crear y elige Página para abrir una página en blanco.\n2. Junto a Compartir, selecciona Más acciones (...) > Plantillas e importación.\n3. Abre la pestaña Importar y selecciona Documento de Word (.docx).\n4. Selecciona «{file}» y elige Abrir.\n5. Cuando termine la importación, selecciona Finalizar. La guía se abre como borrador:\n revisa el título y el contenido y, después, publícala.", + "Confluence Data Center / Server": "Confluence Data Center o Server", + "1. Go to the page the guide should sit under, in view mode (not editing).\n2. Select More options (...) > Import Word Document.\n3. Choose File, pick \"{file}\", and select Next.\n4. Set the options:": "1. Ve a la página bajo la que debe quedar la guía, en modo de lectura (no de edición).\n2. Selecciona Más opciones (...) > Importar documento de Word.\n3. Elige Archivo, selecciona «{file}» y elige Siguiente.\n4. Configura las opciones:", + "5. Select Import.": "5. Selecciona Importar.", + "Tips": "Consejos", + "- Re-importing creates a new page. To update an existing guide, import it\n again and delete the old page, or copy the new content across.": "- Volver a importar crea una página nueva. Para actualizar una guía existente, impórtala\n de nuevo y elimina la página anterior, o copia en ella el contenido nuevo.", + "Optional: publish with the REST API": "Opcional: publicar con la API REST", + "rest-api/ holds the same page for scripts and admins with API access:": "rest-api/ contiene la misma página, para scripts y administradores con acceso a la API:", + "The page body, in Confluence storage format.": "El cuerpo de la página, en formato de almacenamiento de Confluence.", + "The page as a Confluence Cloud REST API request.": "La página como solicitud a la API REST de Confluence Cloud.", + "The page as a Confluence Data Center REST API request.": "La página como solicitud a la API REST de Confluence Data Center.", + "Create the page first, then upload the files in attachments/ to it. The page\nrefers to its screenshots by file name, so they appear as soon as they are\nuploaded. Run these from the rest-api folder in a bash shell with curl\n(macOS, Linux, or Git Bash / WSL on Windows).": "Crea primero la página y después sube a ella los archivos de attachments/. La página\nhace referencia a sus capturas por nombre de archivo, así que aparecen en cuanto\nse suben. Ejecuta estos comandos desde la carpeta rest-api, en un shell bash con curl\n(macOS, Linux, o Git Bash / WSL en Windows).", + "Confluence Cloud: use your Atlassian email and an API token from": "Confluence Cloud: usa tu correo de Atlassian y un token de API creado en", + "1. Find the ID of the space (KEY is the space key from its URL):": "1. Busca el ID del espacio (KEY es la clave del espacio, que aparece en su URL):", + "2. In page-cloud.json, replace SPACE_ID with the \"id\" from that response.\n To nest the page, also add \"parentId\": \"PARENT_PAGE_ID\", next to it.": "2. En page-cloud.json, sustituye SPACE_ID por el \"id\" de esa respuesta.\n Para anidar la página, añade también \"parentId\": \"PARENT_PAGE_ID\", a su lado.", + "3. Create the page, then note the \"id\" in the response:": "3. Crea la página y anota el \"id\" de la respuesta:", + "4. Upload the screenshots (replace PAGE_ID):": "4. Sube las capturas de pantalla (sustituye PAGE_ID):", + "Confluence Data Center: use a personal access token (Profile > Personal\nAccess Tokens).": "Confluence Data Center: usa un token de acceso personal (Perfil > Tokens\nde acceso personal).", + "1. In page-datacenter.json, replace SPACEKEY with the space key. To nest the\n page, add \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], next to \"space\".": "1. En page-datacenter.json, sustituye SPACEKEY por la clave del espacio. Para anidar la\n página, añade \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], junto a \"space\".", + "2. Create the page, then note the \"id\" in the response:": "2. Crea la página y anota el \"id\" de la respuesta:", + "3. Upload the screenshots (replace PAGE_ID):": "3. Sube las capturas de pantalla (sustituye PAGE_ID):", + "{count} screenshots the page shows.": {"one":"{count} captura de pantalla que muestra la página.","other":"{count} capturas de pantalla que muestra la página."}, + "Author: {name}": "Autor: {name}", + "Co-authors: {names}": "Coautores: {names}", + "Organization: {name}": "Organización: {name}", + "Guide version: {version}": "Versión de la guía: {version}", + "Software version: {version}": "Versión del software: {version}", + "Last verified: {date}": "Última verificación: {date}", + "Review again on: {date}": "Volver a revisar el: {date}", + "{count} steps · generated {date}": {"one":"{count} paso · generado el {date}","other":"{count} pasos · generado el {date}"}, + "{count} steps — generated {date}": {"one":"{count} paso — generado el {date}","other":"{count} pasos — generado el {date}"}, + "Page {page} of {total}": "Página {page} de {total}", + "Table of contents|Contents": "Contenido", + "Contents (continued)": "Contenido (continuación)", + "Updated {date}": "Actualizado el {date}", + "Go to step {number}: {title}": "Ir al paso {number}: {title}", + "Mark step as done": "Marcar el paso como hecho", + "Made with StepForge": "Hecho con StepForge", + "Copy failed": "No se pudo copiar", + "Progress": "Progreso", + "{done} of {total} done": "{done} de {total} hechos", + "Screenshot for step {number}: {title}": "Captura de pantalla del paso {number}: {title}", + "Screenshot for step {number}": "Captura de pantalla del paso {number}", + "Flowchart for step {number}: {title}": "Diagrama de flujo del paso {number}: {title}", + "Flowchart for step {number}": "Diagrama de flujo del paso {number}", + "Guides on this site are shared by direct link only. Ask the person who shared a guide for its link.": "Las guías de este sitio solo se comparten mediante enlace directo. Pide el enlace a la persona que compartió la guía.", }); diff --git a/core/locales/fr.js b/core/locales/fr.js index fbf242b3..cdfb3963 100644 --- a/core/locales/fr.js +++ b/core/locales/fr.js @@ -1897,4 +1897,65 @@ "Reset zoom to 100%": "Rétablir le zoom à 100 %", "Background": "Arrière-plan", "This flowchart has as many pictures as it can hold. Remove one, or use smaller pictures.": "Cet organigramme contient déjà autant d’images que possible. Retirez-en une ou utilisez des images plus petites.", + "(Skipped)": "(Ignorée)", + "(skipped)": "(ignorée)", + "{label}: {title}": "{label} : {title}", + "{label}:": "{label} :", + "Step {number}: {title}": "Étape {number} : {title}", + "Go to: {links}": "Aller à : {links}", + "Root page title:": "Titre de la page racine :", + "Where to import:": "Où importer :", + "Import as a new page in the current space": "Importer comme nouvelle page dans l’espace actuel", + "Split by heading:": "Diviser par titre :", + "Don't split": "Ne pas diviser", + "How to add \"{title}\" to Confluence": "Comment ajouter « {title} » à Confluence", + "Import this Word document from your browser:": "Importez ce document Word depuis votre navigateur :", + "No admin rights or API access needed, just permission to create pages in\nthe space. The screenshots come in with the document.": "Aucun droit d’administrateur ni accès à l’API n’est nécessaire, seulement le droit\nde créer des pages dans l’espace. Les captures d’écran sont importées avec le document.", + "Confluence Cloud (your-site.atlassian.net)": "Confluence Cloud (votre-site.atlassian.net)", + "1. In the space, select + Create and choose Page to open a blank page.\n2. Next to Share, select More actions (...) > Templates and import.\n3. Open the Import tab and select Word document (.docx).\n4. Select \"{file}\" and choose Open.\n5. When the import finishes, select Finish. The guide opens as a draft:\n check the title and content, then Publish.": "1. Dans l’espace, sélectionnez + Créer, puis Page pour ouvrir une page vierge.\n2. À côté de Partager, sélectionnez Plus d’actions (...) > Modèles et importation.\n3. Ouvrez l’onglet Importer et sélectionnez Document Word (.docx).\n4. Sélectionnez « {file} » et choisissez Ouvrir.\n5. Une fois l’importation terminée, sélectionnez Terminer. Le guide s’ouvre en brouillon :\n vérifiez le titre et le contenu, puis publiez-le.", + "Confluence Data Center / Server": "Confluence Data Center ou Server", + "1. Go to the page the guide should sit under, in view mode (not editing).\n2. Select More options (...) > Import Word Document.\n3. Choose File, pick \"{file}\", and select Next.\n4. Set the options:": "1. Allez sur la page sous laquelle le guide doit se trouver, en mode lecture (pas en modification).\n2. Sélectionnez Plus d’options (...) > Importer un document Word.\n3. Choisissez Fichier, sélectionnez « {file} », puis Suivant.\n4. Réglez les options :", + "5. Select Import.": "5. Sélectionnez Importer.", + "Tips": "Conseils", + "- Re-importing creates a new page. To update an existing guide, import it\n again and delete the old page, or copy the new content across.": "- Une nouvelle importation crée une nouvelle page. Pour mettre à jour un guide existant,\n importez-le de nouveau et supprimez l’ancienne page, ou copiez-y le nouveau contenu.", + "Optional: publish with the REST API": "Facultatif : publier avec l’API REST", + "rest-api/ holds the same page for scripts and admins with API access:": "rest-api/ contient la même page, pour les scripts et les administrateurs ayant accès à l’API :", + "The page body, in Confluence storage format.": "Le corps de la page, au format de stockage Confluence.", + "The page as a Confluence Cloud REST API request.": "La page sous forme de requête à l’API REST de Confluence Cloud.", + "The page as a Confluence Data Center REST API request.": "La page sous forme de requête à l’API REST de Confluence Data Center.", + "Create the page first, then upload the files in attachments/ to it. The page\nrefers to its screenshots by file name, so they appear as soon as they are\nuploaded. Run these from the rest-api folder in a bash shell with curl\n(macOS, Linux, or Git Bash / WSL on Windows).": "Créez d’abord la page, puis ajoutez-y les fichiers de attachments/. La page\nfait référence à ses captures d’écran par nom de fichier : elles apparaissent dès\nqu’elles sont ajoutées. Lancez ces commandes depuis le dossier rest-api, dans un shell\nbash avec curl (macOS, Linux, ou Git Bash / WSL sous Windows).", + "Confluence Cloud: use your Atlassian email and an API token from": "Confluence Cloud : utilisez votre adresse e-mail Atlassian et un jeton d’API créé sur", + "1. Find the ID of the space (KEY is the space key from its URL):": "1. Trouvez l’ID de l’espace (KEY est la clé de l’espace, visible dans son URL) :", + "2. In page-cloud.json, replace SPACE_ID with the \"id\" from that response.\n To nest the page, also add \"parentId\": \"PARENT_PAGE_ID\", next to it.": "2. Dans page-cloud.json, remplacez SPACE_ID par le \"id\" de cette réponse.\n Pour imbriquer la page, ajoutez aussi \"parentId\": \"PARENT_PAGE_ID\", à côté.", + "3. Create the page, then note the \"id\" in the response:": "3. Créez la page, puis notez le \"id\" de la réponse :", + "4. Upload the screenshots (replace PAGE_ID):": "4. Ajoutez les captures d’écran (remplacez PAGE_ID) :", + "Confluence Data Center: use a personal access token (Profile > Personal\nAccess Tokens).": "Confluence Data Center : utilisez un jeton d’accès personnel (Profil > Jetons\nd’accès personnels).", + "1. In page-datacenter.json, replace SPACEKEY with the space key. To nest the\n page, add \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], next to \"space\".": "1. Dans page-datacenter.json, remplacez SPACEKEY par la clé de l’espace. Pour imbriquer\n la page, ajoutez \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], à côté de \"space\".", + "2. Create the page, then note the \"id\" in the response:": "2. Créez la page, puis notez le \"id\" de la réponse :", + "3. Upload the screenshots (replace PAGE_ID):": "3. Ajoutez les captures d’écran (remplacez PAGE_ID) :", + "{count} screenshots the page shows.": {"one":"{count} capture d’écran affichée par la page.","other":"{count} captures d’écran affichées par la page."}, + "Author: {name}": "Auteur : {name}", + "Co-authors: {names}": "Coauteurs : {names}", + "Organization: {name}": "Organisation : {name}", + "Guide version: {version}": "Version du guide : {version}", + "Software version: {version}": "Version du logiciel : {version}", + "Last verified: {date}": "Dernière vérification : {date}", + "Review again on: {date}": "À revoir le : {date}", + "{count} steps · generated {date}": {"one":"{count} étape · généré le {date}","other":"{count} étapes · généré le {date}"}, + "{count} steps — generated {date}": {"one":"{count} étape — généré le {date}","other":"{count} étapes — généré le {date}"}, + "Page {page} of {total}": "Page {page} sur {total}", + "Table of contents|Contents": "Sommaire", + "Contents (continued)": "Sommaire (suite)", + "Updated {date}": "Mis à jour le {date}", + "Go to step {number}: {title}": "Aller à l’étape {number} : {title}", + "Mark step as done": "Marquer l’étape comme terminée", + "Made with StepForge": "Créé avec StepForge", + "Copy failed": "Échec de la copie", + "Progress": "Progression", + "{done} of {total} done": "{done} sur {total} terminées", + "Screenshot for step {number}: {title}": "Capture d’écran de l’étape {number} : {title}", + "Screenshot for step {number}": "Capture d’écran de l’étape {number}", + "Flowchart for step {number}: {title}": "Organigramme de l’étape {number} : {title}", + "Flowchart for step {number}": "Organigramme de l’étape {number}", + "Guides on this site are shared by direct link only. Ask the person who shared a guide for its link.": "Les guides de ce site ne sont partagés que par lien direct. Demandez le lien à la personne qui a partagé le guide.", }); diff --git a/core/locales/ja.js b/core/locales/ja.js index f00ee05a..6a782a7a 100644 --- a/core/locales/ja.js +++ b/core/locales/ja.js @@ -1897,4 +1897,65 @@ "Reset zoom to 100%": "ズームを 100% に戻す", "Background": "背景", "This flowchart has as many pictures as it can hold. Remove one, or use smaller pictures.": "このフローチャートに入れられる画像は上限に達しています。1 枚削除するか、小さい画像を使ってください。", + "(Skipped)": "(スキップ)", + "(skipped)": "(スキップ)", + "{label}: {title}": "{label}:{title}", + "{label}:": "{label}:", + "Step {number}: {title}": "ステップ {number}:{title}", + "Go to: {links}": "移動先:{links}", + "Root page title:": "ルート ページのタイトル:", + "Where to import:": "インポート先:", + "Import as a new page in the current space": "現在のスペースに新しいページとしてインポート", + "Split by heading:": "見出しで分割:", + "Don't split": "分割しない", + "How to add \"{title}\" to Confluence": "「{title}」を Confluence に追加する方法", + "Import this Word document from your browser:": "ブラウザーからこの Word 文書をインポートします:", + "No admin rights or API access needed, just permission to create pages in\nthe space. The screenshots come in with the document.": "管理者権限や API アクセスは不要で、スペースにページを作成する権限があれば十分です。\nスクリーンショットは文書と一緒にインポートされます。", + "Confluence Cloud (your-site.atlassian.net)": "Confluence Cloud(your-site.atlassian.net)", + "1. In the space, select + Create and choose Page to open a blank page.\n2. Next to Share, select More actions (...) > Templates and import.\n3. Open the Import tab and select Word document (.docx).\n4. Select \"{file}\" and choose Open.\n5. When the import finishes, select Finish. The guide opens as a draft:\n check the title and content, then Publish.": "1. スペースで [+ 作成] を選択し、[ページ] を選んで空白のページを開きます。\n2. [共有] の横にある [その他のアクション (...)] > [テンプレートとインポート] を選択します。\n3. [インポート] タブを開き、[Word ドキュメント (.docx)] を選択します。\n4. 「{file}」を選択し、[開く] を選びます。\n5. インポートが終わったら [完了] を選択します。ガイドは下書きとして開きます。\n タイトルと内容を確認してから公開してください。", + "Confluence Data Center / Server": "Confluence Data Center / Server 版", + "1. Go to the page the guide should sit under, in view mode (not editing).\n2. Select More options (...) > Import Word Document.\n3. Choose File, pick \"{file}\", and select Next.\n4. Set the options:": "1. ガイドを配置する親ページを、閲覧モード(編集モードではなく)で開きます。\n2. [その他のオプション (...)] > [Word 文書のインポート] を選択します。\n3. [ファイル] を選び、「{file}」を指定して [次へ] を選択します。\n4. 次のオプションを設定します:", + "5. Select Import.": "5. [インポート] を選択します。", + "Tips": "ヒント", + "- Re-importing creates a new page. To update an existing guide, import it\n again and delete the old page, or copy the new content across.": "- もう一度インポートすると新しいページが作成されます。既存のガイドを更新するには、\n 再度インポートして古いページを削除するか、新しい内容をコピーしてください。", + "Optional: publish with the REST API": "オプション:REST API で公開する", + "rest-api/ holds the same page for scripts and admins with API access:": "rest-api/ には、スクリプトや API アクセス権を持つ管理者向けに同じページが入っています:", + "The page body, in Confluence storage format.": "ページ本文(Confluence ストレージ形式)。", + "The page as a Confluence Cloud REST API request.": "Confluence Cloud REST API リクエストとしてのページ。", + "The page as a Confluence Data Center REST API request.": "Confluence Data Center REST API リクエストとしてのページ。", + "Create the page first, then upload the files in attachments/ to it. The page\nrefers to its screenshots by file name, so they appear as soon as they are\nuploaded. Run these from the rest-api folder in a bash shell with curl\n(macOS, Linux, or Git Bash / WSL on Windows).": "まずページを作成し、attachments/ 内のファイルをそのページにアップロードします。ページは\nファイル名でスクリーンショットを参照するため、アップロードするとすぐに表示されます。\nこれらのコマンドは rest-api フォルダーで、curl が使える bash シェルから実行してください\n(macOS、Linux、または Windows の Git Bash / WSL)。", + "Confluence Cloud: use your Atlassian email and an API token from": "Confluence Cloud:Atlassian のメールアドレスと、次のページで作成した API トークンを使います:", + "1. Find the ID of the space (KEY is the space key from its URL):": "1. スペースの ID を調べます(KEY はスペースの URL にあるスペースキーです):", + "2. In page-cloud.json, replace SPACE_ID with the \"id\" from that response.\n To nest the page, also add \"parentId\": \"PARENT_PAGE_ID\", next to it.": "2. page-cloud.json の SPACE_ID を、その応答の \"id\" に置き換えます。\n ページを入れ子にするには、その隣に \"parentId\": \"PARENT_PAGE_ID\", も追加します。", + "3. Create the page, then note the \"id\" in the response:": "3. ページを作成し、応答の \"id\" を控えておきます:", + "4. Upload the screenshots (replace PAGE_ID):": "4. スクリーンショットをアップロードします(PAGE_ID を置き換えます):", + "Confluence Data Center: use a personal access token (Profile > Personal\nAccess Tokens).": "Confluence Data Center:個人用アクセス トークンを使います([プロフィール] > [個人用アクセス トークン])。", + "1. In page-datacenter.json, replace SPACEKEY with the space key. To nest the\n page, add \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], next to \"space\".": "1. page-datacenter.json の SPACEKEY をスペースキーに置き換えます。ページを入れ子にするには、\n \"space\" の隣に \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], を追加します。", + "2. Create the page, then note the \"id\" in the response:": "2. ページを作成し、応答の \"id\" を控えておきます:", + "3. Upload the screenshots (replace PAGE_ID):": "3. スクリーンショットをアップロードします(PAGE_ID を置き換えます):", + "{count} screenshots the page shows.": {"other":"ページに表示される {count} 枚のスクリーンショット。"}, + "Author: {name}": "作成者:{name}", + "Co-authors: {names}": "共同作成者:{names}", + "Organization: {name}": "組織:{name}", + "Guide version: {version}": "ガイドのバージョン:{version}", + "Software version: {version}": "ソフトウェアのバージョン:{version}", + "Last verified: {date}": "最終確認日:{date}", + "Review again on: {date}": "次回の見直し:{date}", + "{count} steps · generated {date}": {"other":"{count} ステップ · {date} に作成"}, + "{count} steps — generated {date}": {"other":"{count} ステップ — {date} に作成"}, + "Page {page} of {total}": "{page} / {total} ページ", + "Table of contents|Contents": "目次", + "Contents (continued)": "目次(続き)", + "Updated {date}": "{date} 更新", + "Go to step {number}: {title}": "ステップ {number} へ移動:{title}", + "Mark step as done": "ステップを完了にする", + "Made with StepForge": "StepForge で作成", + "Copy failed": "コピーできませんでした", + "Progress": "進捗", + "{done} of {total} done": "{total} 件中 {done} 件完了", + "Screenshot for step {number}: {title}": "ステップ {number} のスクリーンショット:{title}", + "Screenshot for step {number}": "ステップ {number} のスクリーンショット", + "Flowchart for step {number}: {title}": "ステップ {number} のフローチャート:{title}", + "Flowchart for step {number}": "ステップ {number} のフローチャート", + "Guides on this site are shared by direct link only. Ask the person who shared a guide for its link.": "このサイトのガイドは直接リンクでのみ共有されています。リンクはガイドを共有した人に尋ねてください。", }); diff --git a/core/locales/ru.js b/core/locales/ru.js index f1deb7c1..4543f5c4 100644 --- a/core/locales/ru.js +++ b/core/locales/ru.js @@ -1897,4 +1897,65 @@ "Reset zoom to 100%": "Сбросить масштаб до 100%", "Background": "Фон", "This flowchart has as many pictures as it can hold. Remove one, or use smaller pictures.": "В этой схеме уже максимум изображений. Удалите одно или используйте изображения меньшего размера.", + "(Skipped)": "(Пропущен)", + "(skipped)": "(пропущен)", + "{label}: {title}": "{label}: {title}", + "{label}:": "{label}:", + "Step {number}: {title}": "Шаг {number}: {title}", + "Go to: {links}": "Перейти: {links}", + "Root page title:": "Название корневой страницы:", + "Where to import:": "Куда импортировать:", + "Import as a new page in the current space": "Импортировать как новую страницу в текущем пространстве", + "Split by heading:": "Разделять по заголовкам:", + "Don't split": "Не разделять", + "How to add \"{title}\" to Confluence": "Как добавить «{title}» в Confluence", + "Import this Word document from your browser:": "Импортируйте этот документ Word в браузере:", + "No admin rights or API access needed, just permission to create pages in\nthe space. The screenshots come in with the document.": "Права администратора и доступ к API не нужны, достаточно права создавать страницы\nв пространстве. Снимки экрана импортируются вместе с документом.", + "Confluence Cloud (your-site.atlassian.net)": "Confluence Cloud (адрес вида your-site.atlassian.net)", + "1. In the space, select + Create and choose Page to open a blank page.\n2. Next to Share, select More actions (...) > Templates and import.\n3. Open the Import tab and select Word document (.docx).\n4. Select \"{file}\" and choose Open.\n5. When the import finishes, select Finish. The guide opens as a draft:\n check the title and content, then Publish.": "1. В пространстве выберите + Создать, затем Страница, чтобы открыть пустую страницу.\n2. Рядом с кнопкой Поделиться выберите Другие действия (...) > Шаблоны и импорт.\n3. Откройте вкладку Импорт и выберите Документ Word (.docx).\n4. Выберите «{file}» и нажмите Открыть.\n5. Когда импорт завершится, нажмите Готово. Руководство откроется как черновик:\n проверьте название и содержимое, затем опубликуйте его.", + "Confluence Data Center / Server": "Confluence Data Center или Server", + "1. Go to the page the guide should sit under, in view mode (not editing).\n2. Select More options (...) > Import Word Document.\n3. Choose File, pick \"{file}\", and select Next.\n4. Set the options:": "1. Откройте страницу, под которой должно быть руководство, в режиме просмотра (не редактирования).\n2. Выберите Другие параметры (...) > Импортировать документ Word.\n3. Выберите Файл, укажите «{file}» и нажмите Далее.\n4. Задайте параметры:", + "5. Select Import.": "5. Нажмите Импорт.", + "Tips": "Советы", + "- Re-importing creates a new page. To update an existing guide, import it\n again and delete the old page, or copy the new content across.": "- Повторный импорт создаёт новую страницу. Чтобы обновить руководство, импортируйте\n его снова и удалите старую страницу или перенесите новое содержимое вручную.", + "Optional: publish with the REST API": "Необязательно: публикация через REST API", + "rest-api/ holds the same page for scripts and admins with API access:": "В папке rest-api/ та же страница — для скриптов и администраторов с доступом к API:", + "The page body, in Confluence storage format.": "Текст страницы в формате хранения Confluence.", + "The page as a Confluence Cloud REST API request.": "Страница в виде запроса к REST API Confluence Cloud.", + "The page as a Confluence Data Center REST API request.": "Страница в виде запроса к REST API Confluence Data Center.", + "Create the page first, then upload the files in attachments/ to it. The page\nrefers to its screenshots by file name, so they appear as soon as they are\nuploaded. Run these from the rest-api folder in a bash shell with curl\n(macOS, Linux, or Git Bash / WSL on Windows).": "Сначала создайте страницу, затем загрузите в неё файлы из attachments/. Страница\nссылается на снимки экрана по имени файла, поэтому они появятся сразу после\nзагрузки. Выполняйте эти команды из папки rest-api в оболочке bash с curl\n(macOS, Linux или Git Bash / WSL в Windows).", + "Confluence Cloud: use your Atlassian email and an API token from": "Confluence Cloud: используйте адрес электронной почты Atlassian и API-токен со страницы", + "1. Find the ID of the space (KEY is the space key from its URL):": "1. Узнайте ID пространства (KEY — ключ пространства из его URL):", + "2. In page-cloud.json, replace SPACE_ID with the \"id\" from that response.\n To nest the page, also add \"parentId\": \"PARENT_PAGE_ID\", next to it.": "2. В page-cloud.json замените SPACE_ID на \"id\" из этого ответа.\n Чтобы вложить страницу, добавьте рядом \"parentId\": \"PARENT_PAGE_ID\",", + "3. Create the page, then note the \"id\" in the response:": "3. Создайте страницу и запишите \"id\" из ответа:", + "4. Upload the screenshots (replace PAGE_ID):": "4. Загрузите снимки экрана (замените PAGE_ID):", + "Confluence Data Center: use a personal access token (Profile > Personal\nAccess Tokens).": "Confluence Data Center: используйте личный токен доступа (Профиль > Личные\nтокены доступа).", + "1. In page-datacenter.json, replace SPACEKEY with the space key. To nest the\n page, add \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], next to \"space\".": "1. В page-datacenter.json замените SPACEKEY на ключ пространства. Чтобы вложить\n страницу, добавьте \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], рядом с \"space\".", + "2. Create the page, then note the \"id\" in the response:": "2. Создайте страницу и запишите \"id\" из ответа:", + "3. Upload the screenshots (replace PAGE_ID):": "3. Загрузите снимки экрана (замените PAGE_ID):", + "{count} screenshots the page shows.": {"one":"{count} снимок экрана для страницы.","few":"{count} снимка экрана для страницы.","many":"{count} снимков экрана для страницы.","other":"{count} снимка экрана для страницы."}, + "Author: {name}": "Автор: {name}", + "Co-authors: {names}": "Соавторы: {names}", + "Organization: {name}": "Организация: {name}", + "Guide version: {version}": "Версия руководства: {version}", + "Software version: {version}": "Версия программы: {version}", + "Last verified: {date}": "Последняя проверка: {date}", + "Review again on: {date}": "Проверить снова: {date}", + "{count} steps · generated {date}": {"one":"{count} шаг · создано {date}","few":"{count} шага · создано {date}","many":"{count} шагов · создано {date}","other":"{count} шага · создано {date}"}, + "{count} steps — generated {date}": {"one":"{count} шаг — создано {date}","few":"{count} шага — создано {date}","many":"{count} шагов — создано {date}","other":"{count} шага — создано {date}"}, + "Page {page} of {total}": "Страница {page} из {total}", + "Table of contents|Contents": "Содержание", + "Contents (continued)": "Содержание (продолжение)", + "Updated {date}": "Обновлено {date}", + "Go to step {number}: {title}": "Перейти к шагу {number}: {title}", + "Mark step as done": "Отметить шаг как выполненный", + "Made with StepForge": "Сделано в StepForge", + "Copy failed": "Не удалось скопировать", + "Progress": "Прогресс", + "{done} of {total} done": "Выполнено: {done} из {total}", + "Screenshot for step {number}: {title}": "Снимок экрана к шагу {number}: {title}", + "Screenshot for step {number}": "Снимок экрана к шагу {number}", + "Flowchart for step {number}: {title}": "Блок-схема к шагу {number}: {title}", + "Flowchart for step {number}": "Блок-схема к шагу {number}", + "Guides on this site are shared by direct link only. Ask the person who shared a guide for its link.": "Руководства на этом сайте доступны только по прямой ссылке. Попросите ссылку у того, кто поделился руководством.", }); diff --git a/core/locales/zh-CN.js b/core/locales/zh-CN.js index b3d5e3a1..261cec63 100644 --- a/core/locales/zh-CN.js +++ b/core/locales/zh-CN.js @@ -1897,4 +1897,65 @@ "Reset zoom to 100%": "将缩放重置为 100%", "Background": "背景", "This flowchart has as many pictures as it can hold. Remove one, or use smaller pictures.": "此流程图中的图片已达上限。请删除一张,或使用更小的图片。", + "(Skipped)": "(已跳过)", + "(skipped)": "(已跳过)", + "{label}: {title}": "{label}:{title}", + "{label}:": "{label}:", + "Step {number}: {title}": "步骤 {number}:{title}", + "Go to: {links}": "转到:{links}", + "Root page title:": "根页面标题:", + "Where to import:": "导入位置:", + "Import as a new page in the current space": "作为新页面导入当前空间", + "Split by heading:": "按标题拆分:", + "Don't split": "不拆分", + "How to add \"{title}\" to Confluence": "如何将“{title}”添加到 Confluence", + "Import this Word document from your browser:": "在浏览器中导入此 Word 文档:", + "No admin rights or API access needed, just permission to create pages in\nthe space. The screenshots come in with the document.": "无需管理员权限或 API 访问权限,只需有在该空间中创建页面的权限。\n截图会随文档一起导入。", + "Confluence Cloud (your-site.atlassian.net)": "Confluence Cloud(your-site.atlassian.net)", + "1. In the space, select + Create and choose Page to open a blank page.\n2. Next to Share, select More actions (...) > Templates and import.\n3. Open the Import tab and select Word document (.docx).\n4. Select \"{file}\" and choose Open.\n5. When the import finishes, select Finish. The guide opens as a draft:\n check the title and content, then Publish.": "1. 在空间中选择“+ 创建”,然后选择“页面”,打开一个空白页面。\n2. 在“共享”旁边,选择“更多操作 (...)”>“模板和导入”。\n3. 打开“导入”选项卡,选择“Word 文档 (.docx)”。\n4. 选择“{file}”,然后选择“打开”。\n5. 导入完成后,选择“完成”。指南会以草稿形式打开:\n 检查标题和内容,然后发布。", + "Confluence Data Center / Server": "Confluence Data Center / Server 版", + "1. Go to the page the guide should sit under, in view mode (not editing).\n2. Select More options (...) > Import Word Document.\n3. Choose File, pick \"{file}\", and select Next.\n4. Set the options:": "1. 以查看模式(而非编辑模式)打开要放置指南的上级页面。\n2. 选择“更多选项 (...)”>“导入 Word 文档”。\n3. 选择“文件”,选取“{file}”,然后选择“下一步”。\n4. 设置以下选项:", + "5. Select Import.": "5. 选择“导入”。", + "Tips": "提示", + "- Re-importing creates a new page. To update an existing guide, import it\n again and delete the old page, or copy the new content across.": "- 重新导入会创建新页面。要更新现有指南,请再次导入并删除旧页面,\n 或将新内容复制到旧页面中。", + "Optional: publish with the REST API": "可选:使用 REST API 发布", + "rest-api/ holds the same page for scripts and admins with API access:": "rest-api/ 中是同一页面,供脚本和有 API 访问权限的管理员使用:", + "The page body, in Confluence storage format.": "页面正文,采用 Confluence 存储格式。", + "The page as a Confluence Cloud REST API request.": "该页面的 Confluence Cloud REST API 请求。", + "The page as a Confluence Data Center REST API request.": "该页面的 Confluence Data Center REST API 请求。", + "Create the page first, then upload the files in attachments/ to it. The page\nrefers to its screenshots by file name, so they appear as soon as they are\nuploaded. Run these from the rest-api folder in a bash shell with curl\n(macOS, Linux, or Git Bash / WSL on Windows).": "请先创建页面,再将 attachments/ 中的文件上传到该页面。页面按文件名引用截图,\n因此上传后截图会立即显示。请在 rest-api 文件夹中,用装有 curl 的 bash shell 运行\n以下命令(macOS、Linux,或 Windows 上的 Git Bash / WSL)。", + "Confluence Cloud: use your Atlassian email and an API token from": "Confluence Cloud:使用你的 Atlassian 电子邮件地址和 API 令牌,令牌可在此创建:", + "1. Find the ID of the space (KEY is the space key from its URL):": "1. 查找空间的 ID(KEY 是空间 URL 中的空间键):", + "2. In page-cloud.json, replace SPACE_ID with the \"id\" from that response.\n To nest the page, also add \"parentId\": \"PARENT_PAGE_ID\", next to it.": "2. 在 page-cloud.json 中,将 SPACE_ID 替换为该响应中的 \"id\"。\n 要将页面嵌套在其他页面下,还需在旁边添加 \"parentId\": \"PARENT_PAGE_ID\",", + "3. Create the page, then note the \"id\" in the response:": "3. 创建页面,并记下响应中的 \"id\":", + "4. Upload the screenshots (replace PAGE_ID):": "4. 上传截图(替换 PAGE_ID):", + "Confluence Data Center: use a personal access token (Profile > Personal\nAccess Tokens).": "Confluence Data Center:使用个人访问令牌(个人资料 > 个人访问令牌)。", + "1. In page-datacenter.json, replace SPACEKEY with the space key. To nest the\n page, add \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}], next to \"space\".": "1. 在 page-datacenter.json 中,将 SPACEKEY 替换为空间键。要嵌套页面,\n 请在 \"space\" 旁边添加 \"ancestors\": [{\"id\": \"PARENT_PAGE_ID\"}],", + "2. Create the page, then note the \"id\" in the response:": "2. 创建页面,并记下响应中的 \"id\":", + "3. Upload the screenshots (replace PAGE_ID):": "3. 上传截图(替换 PAGE_ID):", + "{count} screenshots the page shows.": {"other":"页面显示的 {count} 张截图。"}, + "Author: {name}": "作者:{name}", + "Co-authors: {names}": "合著者:{names}", + "Organization: {name}": "组织:{name}", + "Guide version: {version}": "指南版本:{version}", + "Software version: {version}": "软件版本:{version}", + "Last verified: {date}": "上次验证:{date}", + "Review again on: {date}": "下次审阅:{date}", + "{count} steps · generated {date}": {"other":"{count} 个步骤 · 生成于 {date}"}, + "{count} steps — generated {date}": {"other":"{count} 个步骤 — 生成于 {date}"}, + "Page {page} of {total}": "第 {page} 页,共 {total} 页", + "Table of contents|Contents": "目录", + "Contents (continued)": "目录(续)", + "Updated {date}": "更新于 {date}", + "Go to step {number}: {title}": "转到步骤 {number}:{title}", + "Mark step as done": "将步骤标记为已完成", + "Made with StepForge": "使用 StepForge 制作", + "Copy failed": "复制失败", + "Progress": "进度", + "{done} of {total} done": "已完成 {done}/{total}", + "Screenshot for step {number}: {title}": "步骤 {number} 的截图:{title}", + "Screenshot for step {number}": "步骤 {number} 的截图", + "Flowchart for step {number}: {title}": "步骤 {number} 的流程图:{title}", + "Flowchart for step {number}": "步骤 {number} 的流程图", + "Guides on this site are shared by direct link only. Ask the person who shared a guide for its link.": "本网站上的指南仅通过直接链接分享。请向分享指南的人索取链接。", }); diff --git a/core/pages-site.js b/core/pages-site.js index 5f430604..418d646e 100644 --- a/core/pages-site.js +++ b/core/pages-site.js @@ -1,6 +1,8 @@ 'use strict'; const crypto = require('node:crypto'); +const { escapeHtml } = require('./util'); +const { exportTranslator } = require('./export-text'); /* * Layout and rules for a guide-sharing site hosted on GitHub Pages in the @@ -132,7 +134,8 @@ function planSite(manifest, { now = Date.now(), publish = null, remove = null, e const ROBOTS_META = ''; const REFERRER_META = ''; const PROJECT_URL = 'https://github.com/Twest2/StepForge'; -const FOOTER_CREDIT = '