layoutbridge-light 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.de.md +31 -2
  3. package/README.md +31 -3
  4. package/THIRD-PARTY-NOTICES.md +2 -2
  5. package/dist/src/mcp/catalog.js +9 -4
  6. package/dist/src/mcp/content-credentials.js +65 -0
  7. package/dist/src/mcp/dispatch.js +8 -1
  8. package/dist/src/mcp/license/polar-ids.js +1 -1
  9. package/dist/src/mcp/pdf-postprocess.js +798 -0
  10. package/dist/src/mcp/server.js +1 -1
  11. package/dist/src/mcp/target-folder.js +25 -0
  12. package/dist/src/mcp/tools.js +275 -20
  13. package/dist/src/mcp/typography-rules.js +1 -1
  14. package/dist/src/transport/com.js +224 -38
  15. package/dist/src/transport/transport.js +22 -0
  16. package/indesign/commands/25-export.js +258 -11
  17. package/indesign/commands/26-export-image.js +1 -1
  18. package/indesign/commands/31-view.js +100 -0
  19. package/indesign/commands/35-parents.js +44 -24
  20. package/indesign/commands/40-frames.js +35 -0
  21. package/indesign/commands/47-anchored.js +142 -0
  22. package/indesign/commands/50-modify.js +25 -3
  23. package/indesign/commands/60-images.js +28 -2
  24. package/indesign/commands/69-placeholders.js +7 -2
  25. package/indesign/commands/70-text.js +39 -4
  26. package/indesign/commands/75-tables.js +27 -1
  27. package/indesign/commands/78-frame-lines.js +59 -0
  28. package/indesign/commands/79-check-typography.js +143 -4
  29. package/indesign/commands/80-styles.js +105 -10
  30. package/indesign/commands/89-toc-index.js +61 -4
  31. package/indesign/commands/91-epub.js +92 -9
  32. package/indesign/commands/92-accessibility.js +1131 -0
  33. package/indesign/interpreter.js +51 -1
  34. package/package.json +1 -1
  35. package/rules/typography-rules.json +3 -2
@@ -5,11 +5,20 @@
5
5
  * Windows-Rechner steht; der Interpreter und das Befehlsformat {cmd, args}
6
6
  * bleiben unveraendert.
7
7
  *
8
- * Ablauf je Aufruf:
9
- * 1. Interpreter und Handler zu einem Skript zusammensetzen, den Befehl als
10
- * ASCII-JSON hineinschreiben und in eine temporaere .jsx-Datei legen.
11
- * 2. Eine .ps1-Datei danebenlegen, die sich die Anwendung ueber COM holt und
12
- * ihr das Skript per doScript uebergibt. Erst GetActiveObject (Running
8
+ * Ablauf je Aufruf (seit Sitzung 80, A-74):
9
+ * 1. Interpreter und Handler samt festem Rahmen liegen als Skriptdatei in
10
+ * einem festen Arbeitsordner (`%LOCALAPPDATA%\layoutbridge-spm\com`),
11
+ * einmal je Buendelstand geschrieben (Pruefsumme im Namen). Daneben ein
12
+ * festes PowerShell-Skript. Je Aufruf entstehen nur Datendateien: der
13
+ * Befehl als ASCII-JSON und das Ergebnis. Vorher entstanden je Aufruf
14
+ * zwei neue Skriptdateien (.jsx mit ~0,9 MB, .ps1) in einem neuen
15
+ * Temp-Ordner; ein Virenschutz mit Echtzeitpruefung (beim Kunden Avira,
16
+ * per Test bestaetigt 10.11.2026) hielt jede davon an – Aufrufe 12 s bis
17
+ * 122 s statt unter einer Sekunde. Ein fester Ordner laesst sich zudem
18
+ * gezielt als Ausnahme eintragen.
19
+ * 2. PowerShell holt sich die Anwendung ueber COM und uebergibt ihr das
20
+ * Skript per doScript, den Befehl und den Ergebnispfad ueber
21
+ * withArguments (im Skript `arguments`). Erst GetActiveObject (Running
13
22
  * Object Table), dann CoCreateInstance (New-Object -ComObject): InDesign
14
23
  * traegt sich nicht in die Running Object Table ein, CoCreateInstance
15
24
  * verbindet aber mit der laufenden Instanz und startet sie sonst - wie
@@ -29,24 +38,34 @@
29
38
  * der Rueckfall auf CoCreateInstance traegt.
30
39
  */
31
40
  import { spawn } from 'node:child_process';
32
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
41
+ import { createHash, randomBytes } from 'node:crypto';
42
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
33
43
  import { tmpdir } from 'node:os';
34
44
  import { join } from 'node:path';
35
45
  import { buildScriptBundle, toAsciiJson } from './script-bundle.js';
36
- import { transportError, } from './transport.js';
46
+ import { attachTiming, transportError, } from './transport.js';
37
47
  /** ScriptLanguage.javascript im Objektmodell – als Zahl, weil COM keine Aufzaehlung kennt. */
38
48
  export const SCRIPT_LANGUAGE_JAVASCRIPT = 1246973031;
39
49
  /** Zeilen, mit denen sich das PowerShell-Skript meldet, wenn es selbst abbricht. */
40
50
  export const APP_NOT_AVAILABLE_MARKER = 'IDC-COM-ERROR app_not_available:';
41
51
  export const SCRIPT_ERROR_MARKER = 'IDC-COM-ERROR script_error:';
52
+ /** Zeile mit den Zeitanteilen aus PowerShell (nur Protokoll, A-74). */
53
+ export const TIMING_MARKER = 'IDC-COM-TIMING';
54
+ /** Aufgeraeumt wird, was laenger als einen Tag im Arbeitsordner liegt. */
55
+ const STALE_MS = 24 * 60 * 60 * 1000;
56
+ /**
57
+ * Fester Arbeitsordner des Windows-Transports:
58
+ * `%LOCALAPPDATA%\<produkt>\com`, ohne LOCALAPPDATA im Temp-Ordner.
59
+ */
60
+ export function defaultComWorkDir(dataDirName = 'layoutbridge-spm', env = process.env) {
61
+ const local = env['LOCALAPPDATA'];
62
+ const base = local !== undefined && local.length > 0 ? local : tmpdir();
63
+ return join(base, dataDirName, 'com');
64
+ }
42
65
  /** Text in einem PowerShell-Literal mit einfachen Anfuehrungszeichen. */
43
66
  export function escapeForPowerShell(text) {
44
67
  return text.replace(/'/g, "''");
45
68
  }
46
- /** Text in einem JavaScript-Literal mit doppelten Anfuehrungszeichen. */
47
- function escapeForJsString(text) {
48
- return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
49
- }
50
69
  /**
51
70
  * Wertet den Ausgang eines Aufrufs aus. Reine Funktion ohne Dateisystem und
52
71
  * ohne Prozess, damit die Einheitstests jede Fehlerart ohne Windows pruefen.
@@ -95,6 +114,60 @@ function markedMessage(stderr, marker) {
95
114
  const line = (end < 0 ? rest : rest.slice(0, end)).trim();
96
115
  return line.length > 0 ? line : marker.replace(':', '');
97
116
  }
117
+ /** Zieht die Zeitzeile aus stderr: Zeitanteile und der uebrige Text. */
118
+ export function splitTiming(stderr) {
119
+ const timing = {};
120
+ const kept = [];
121
+ for (const line of stderr.split(/\r?\n/)) {
122
+ const at = line.indexOf(TIMING_MARKER);
123
+ if (at < 0) {
124
+ kept.push(line);
125
+ continue;
126
+ }
127
+ for (const pair of line.slice(at + TIMING_MARKER.length).trim().split(/\s+/)) {
128
+ const eq = pair.indexOf('=');
129
+ if (eq <= 0)
130
+ continue;
131
+ const value = Number(pair.slice(eq + 1));
132
+ if (Number.isFinite(value))
133
+ timing['ps_' + pair.slice(0, eq)] = value;
134
+ }
135
+ }
136
+ return { timing, rest: kept.join('\n') };
137
+ }
138
+ /** Kurze Pruefsumme eines Textes fuer Dateinamen. */
139
+ function shortHash(text) {
140
+ return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
141
+ }
142
+ /**
143
+ * Schreibt eine Datei nur, wenn sie mit diesem Inhalt noch nicht da ist
144
+ * (Pruefsumme im Namen, Groesse als Vollstaendigkeitsprobe). Erst unter
145
+ * eigenem Namen, dann umbenennen: Mehrere Serverinstanzen koennen
146
+ * gleichzeitig starten, keine liest eine halb geschriebene Datei.
147
+ * Liefert true, wenn geschrieben wurde.
148
+ */
149
+ export function ensureFile(path, content) {
150
+ const size = Buffer.byteLength(content, 'utf8');
151
+ try {
152
+ if (existsSync(path) && statSync(path).size === size)
153
+ return false;
154
+ }
155
+ catch {
156
+ /* dann neu schreiben */
157
+ }
158
+ const temporary = path + '.' + process.pid + '.' + randomBytes(4).toString('hex') + '.tmp';
159
+ writeFileSync(temporary, content, 'utf8');
160
+ try {
161
+ renameSync(temporary, path);
162
+ }
163
+ catch (error) {
164
+ rmSync(temporary, { force: true });
165
+ /* Eine andere Instanz war schneller und haelt die Datei offen. */
166
+ if (!(existsSync(path) && statSync(path).size === size))
167
+ throw error;
168
+ }
169
+ return true;
170
+ }
98
171
  export class ComTransport {
99
172
  name = 'com';
100
173
  progIds;
@@ -102,44 +175,69 @@ export class ComTransport {
102
175
  defaultTimeoutMs;
103
176
  keepTempFiles;
104
177
  runner;
178
+ workDir;
179
+ sequence = 0;
180
+ cleaned = false;
181
+ /** Zuletzt bereitgelegtes Buendel (gleiches Objekt = gleicher Stand). */
182
+ preparedBundle = null;
183
+ preparedScriptPath = '';
184
+ preparedPowerShellPath = '';
105
185
  constructor(options = {}) {
106
186
  this.progIds = options.progIds ?? ['InDesign.Application'];
107
187
  this.powershellPath = options.powershellPath ?? 'powershell.exe';
108
188
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
109
189
  this.keepTempFiles = options.keepTempFiles ?? false;
110
190
  this.runner = options.runner ?? runProcess;
191
+ this.workDir = options.workDir ?? defaultComWorkDir(options.dataDirName);
111
192
  }
112
- /** Das vollstaendige Skript inklusive Rahmen, der das Ergebnis wegschreibt. */
113
- buildScript(command, resultPath) {
114
- const bundle = buildScriptBundle();
115
- const commandLiteral = toAsciiJson(command);
116
- const target = escapeForJsString(resultPath);
193
+ /**
194
+ * Das vollstaendige Skript: Buendel plus fester Rahmen. Der Befehl steht
195
+ * nicht darin – er kommt ueber withArguments (arguments[0] Befehl als
196
+ * ASCII-JSON, arguments[1] Ergebnispfad). So bleibt die Datei je
197
+ * Buendelstand gleich.
198
+ */
199
+ buildScript(bundle = buildScriptBundle()) {
117
200
  const frame = [
118
201
  '',
119
202
  '/* ---- Rahmen des com-Transports (ExtendScript, Windows) ---- */',
120
- '(function () {',
121
- ' var __text = IDC.run(' + commandLiteral + ');',
122
- ' var __file = new File("' + target + '");',
203
+ 'var __idcArgs = (typeof arguments !== "undefined" && arguments !== null) ? arguments : [];',
204
+ '(function (commandText, resultPath) {',
205
+ ' var __text, __file;',
206
+ ' if (commandText === undefined || commandText === null || resultPath === undefined || resultPath === null) {',
207
+ ' return "missing arguments";',
208
+ ' }',
209
+ ' __text = IDC.run(eval("(" + String(commandText) + ")"));',
210
+ ' __file = new File(String(resultPath));',
123
211
  ' __file.encoding = "UTF-8";',
124
212
  ' __file.lineFeed = "Unix";',
125
213
  ' __file.open("w");',
126
214
  ' __file.write(__text);',
127
215
  ' __file.close();',
128
216
  ' return "ok";',
129
- '})();',
217
+ '})(__idcArgs[0], __idcArgs[1]);',
130
218
  '',
131
219
  ].join('\n');
132
220
  return bundle + '\n' + frame;
133
221
  }
134
222
  /**
135
- * Das PowerShell-Skript: Anwendung ueber COM holen (GetActiveObject, sonst
136
- * New-Object -ComObject), Skripttext lesen, doScript aufrufen. Bricht es selbst ab, meldet es sich mit einer
137
- * Markerzeile, damit die Fehlerart nicht aus Fremdtexten geraten wird.
223
+ * Das feste PowerShell-Skript: Anwendung ueber COM holen (GetActiveObject,
224
+ * sonst New-Object -ComObject), Skript- und Befehlsdatei lesen, doScript
225
+ * mit withArguments aufrufen. Pfade kommen als Parameter. Bricht es selbst
226
+ * ab, meldet es sich mit einer Markerzeile, damit die Fehlerart nicht aus
227
+ * Fremdtexten geraten wird. Zeitanteile stehen in einer eigenen Zeile.
138
228
  */
139
- buildPowerShell(scriptPath) {
229
+ buildPowerShell() {
140
230
  const ids = this.progIds.map((id) => "'" + escapeForPowerShell(id) + "'").join(', ');
141
231
  return [
232
+ 'param([string]$ScriptPath, [string]$CommandPath, [string]$ResultPath)',
142
233
  '$ErrorActionPreference = ' + "'Stop'",
234
+ '$watch = [Diagnostics.Stopwatch]::StartNew()',
235
+ '$marks = @{}',
236
+ 'function Write-IdcTiming {',
237
+ " $line = '" + TIMING_MARKER + "'",
238
+ ' foreach ($key in $marks.Keys) { $line += (" " + $key + "=" + $marks[$key]) }',
239
+ ' [Console]::Error.WriteLine($line)',
240
+ '}',
143
241
  '$progIds = @(' + ids + ')',
144
242
  '$app = $null',
145
243
  '$reasons = @()',
@@ -161,40 +259,125 @@ export class ComTransport {
161
259
  ' }',
162
260
  ' }',
163
261
  '}',
262
+ "$marks['connect'] = $watch.ElapsedMilliseconds",
164
263
  'if ($null -eq $app) {',
264
+ ' Write-IdcTiming',
165
265
  ' [Console]::Error.WriteLine(' + "'" + APP_NOT_AVAILABLE_MARKER +
166
266
  " ' + ($reasons -join '; '))",
167
267
  ' exit 3',
168
268
  '}',
169
- '$scriptText = [IO.File]::ReadAllText(' + "'" + escapeForPowerShell(scriptPath) +
170
- "', [Text.Encoding]::UTF8)",
269
+ '$scriptText = [IO.File]::ReadAllText($ScriptPath, [Text.Encoding]::UTF8)',
270
+ '$commandText = [IO.File]::ReadAllText($CommandPath, [Text.Encoding]::UTF8)',
271
+ "$marks['read'] = $watch.ElapsedMilliseconds - $marks['connect']",
171
272
  'try {',
172
- ' $null = $app.DoScript($scriptText, ' + SCRIPT_LANGUAGE_JAVASCRIPT + ')',
273
+ ' $null = $app.DoScript($scriptText, ' + SCRIPT_LANGUAGE_JAVASCRIPT +
274
+ ', [object[]]@($commandText, $ResultPath))',
173
275
  '} catch {',
276
+ " $marks['doscript'] = $watch.ElapsedMilliseconds - $marks['connect'] - $marks['read']",
277
+ ' Write-IdcTiming',
174
278
  ' [Console]::Error.WriteLine(' + "'" + SCRIPT_ERROR_MARKER +
175
279
  " ' + $_.Exception.Message)",
176
280
  ' exit 4',
177
281
  '}',
282
+ "$marks['doscript'] = $watch.ElapsedMilliseconds - $marks['connect'] - $marks['read']",
283
+ "$marks['total'] = $watch.ElapsedMilliseconds",
284
+ 'Write-IdcTiming',
178
285
  'exit 0',
179
286
  '',
180
287
  ].join('\n');
181
288
  }
289
+ /**
290
+ * Legt Skript- und PowerShell-Datei in den Arbeitsordner, falls noetig.
291
+ * Liefert true, wenn in diesem Aufruf eine davon geschrieben wurde.
292
+ */
293
+ prepare() {
294
+ const bundle = buildScriptBundle();
295
+ if (bundle === this.preparedBundle && existsSync(this.preparedScriptPath) && existsSync(this.preparedPowerShellPath)) {
296
+ return false;
297
+ }
298
+ mkdirSync(this.workDir, { recursive: true });
299
+ const script = this.buildScript(bundle);
300
+ const powerShell = this.buildPowerShell();
301
+ const scriptPath = join(this.workDir, 'script-' + shortHash(script) + '.jsx');
302
+ const powerShellPath = join(this.workDir, 'run-' + shortHash(powerShell) + '.ps1');
303
+ let written = ensureFile(scriptPath, script);
304
+ written = ensureFile(powerShellPath, powerShell) || written;
305
+ this.preparedBundle = bundle;
306
+ this.preparedScriptPath = scriptPath;
307
+ this.preparedPowerShellPath = powerShellPath;
308
+ if (!this.cleaned) {
309
+ this.cleaned = true;
310
+ this.removeStale([scriptPath, powerShellPath]);
311
+ }
312
+ return written;
313
+ }
314
+ /** Alte Dateien frueherer Staende und liegengebliebener Aufrufe entfernen. */
315
+ removeStale(keep) {
316
+ const now = Date.now();
317
+ let names = [];
318
+ try {
319
+ names = readdirSync(this.workDir);
320
+ }
321
+ catch {
322
+ return;
323
+ }
324
+ for (const name of names) {
325
+ const path = join(this.workDir, name);
326
+ if (keep.includes(path))
327
+ continue;
328
+ if (!/^(script-.*\.jsx|run-.*\.ps1|call-.*\.json|.*\.tmp)$/.test(name))
329
+ continue;
330
+ try {
331
+ if (now - statSync(path).mtimeMs > STALE_MS)
332
+ rmSync(path, { force: true });
333
+ }
334
+ catch {
335
+ /* Aufraeumen darf keinen Aufruf scheitern lassen. */
336
+ }
337
+ }
338
+ }
182
339
  async send(command, options = {}) {
183
340
  const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
184
- let directory;
341
+ const timing = { begin: Date.now() };
342
+ let mark = Date.now();
343
+ const lap = (key) => {
344
+ const now = Date.now();
345
+ timing[key] = now - mark;
346
+ mark = now;
347
+ };
348
+ this.sequence += 1;
349
+ const stem = 'call-' + process.pid + '-' + this.sequence + '-' + randomBytes(4).toString('hex');
350
+ const commandPath = join(this.workDir, stem + '.command.json');
351
+ const resultPath = join(this.workDir, stem + '.result.json');
185
352
  try {
186
- directory = mkdtempSync(join(tmpdir(), 'idc-spm-'));
353
+ timing['script_written'] = this.prepare() ? 1 : 0;
354
+ writeFileSync(commandPath, toAsciiJson(command), 'utf8');
187
355
  }
188
356
  catch (error) {
189
- return transportError('io_error', 'Could not create a temporary directory.', String(error));
357
+ return transportError('io_error', 'Could not prepare the script files of the Windows transport.', String(error));
190
358
  }
191
- const scriptPath = join(directory, 'command.jsx');
192
- const resultPath = join(directory, 'result.json');
193
- const powerShellPath = join(directory, 'command.ps1');
359
+ lap('prepare');
194
360
  try {
195
- writeFileSync(scriptPath, this.buildScript(command, resultPath), 'utf8');
196
- writeFileSync(powerShellPath, this.buildPowerShell(scriptPath), 'utf8');
197
- const outcome = await this.runner(this.powershellPath, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', powerShellPath], timeoutMs);
361
+ const outcome = await this.runner(this.powershellPath, [
362
+ '-NoProfile',
363
+ '-NonInteractive',
364
+ '-ExecutionPolicy',
365
+ 'Bypass',
366
+ '-File',
367
+ this.preparedPowerShellPath,
368
+ '-ScriptPath',
369
+ this.preparedScriptPath,
370
+ '-CommandPath',
371
+ commandPath,
372
+ '-ResultPath',
373
+ resultPath,
374
+ ], timeoutMs);
375
+ lap('process');
376
+ const split = splitTiming(outcome.stderr);
377
+ Object.assign(timing, split.timing);
378
+ if (split.timing['ps_total'] !== undefined && timing['process'] !== undefined) {
379
+ timing['ps_start'] = Math.max(0, timing['process'] - split.timing['ps_total']);
380
+ }
198
381
  let raw = null;
199
382
  try {
200
383
  raw = readFileSync(resultPath, 'utf8');
@@ -202,17 +385,20 @@ export class ComTransport {
202
385
  catch {
203
386
  raw = null;
204
387
  }
205
- return interpretOutcome(outcome, raw, command, timeoutMs);
388
+ lap('read_result');
389
+ return attachTiming(interpretOutcome({ ...outcome, stderr: split.rest }, raw, command, timeoutMs), timing);
206
390
  }
207
391
  finally {
208
392
  if (!this.keepTempFiles) {
209
393
  try {
210
- rmSync(directory, { recursive: true, force: true });
394
+ rmSync(commandPath, { force: true });
395
+ rmSync(resultPath, { force: true });
211
396
  }
212
397
  catch {
213
398
  /* Aufraeumen darf keinen Aufruf scheitern lassen. */
214
399
  }
215
400
  }
401
+ lap('cleanup');
216
402
  }
217
403
  }
218
404
  async close() {
@@ -20,6 +20,28 @@ export const ERROR_KINDS = [
20
20
  'script_error',
21
21
  'license',
22
22
  ];
23
+ /**
24
+ * Zeitanteile eines Aufrufs fuer das Protokoll (A-74, Sitzung 80). Haengt
25
+ * unter einem Symbol am Ergebnis: JSON.stringify und Object.keys sehen es
26
+ * nicht, die Antwort an den Client bleibt also unveraendert. Der Aufrufer
27
+ * nimmt es mit takeTiming() ab.
28
+ */
29
+ export const TIMING = Symbol('idc.timing');
30
+ export function attachTiming(result, timing) {
31
+ Object.defineProperty(result, TIMING, { value: timing, enumerable: false, configurable: true });
32
+ return result;
33
+ }
34
+ /** Liest die Zeitanteile und entfernt sie vom Ergebnis; null, wenn keine da sind. */
35
+ export function takeTiming(result) {
36
+ if (result === null || typeof result !== 'object')
37
+ return null;
38
+ const holder = result;
39
+ const timing = holder[TIMING];
40
+ if (timing === undefined)
41
+ return null;
42
+ delete holder[TIMING];
43
+ return timing;
44
+ }
23
45
  /** Baut einen Fehlerumschlag, ohne dass InDesign beteiligt war. */
24
46
  export function transportError(kind, message, details = null) {
25
47
  return { ok: false, warnings: [], error: { kind, message, details } };