execonvert 0.5.4 → 0.5.6

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,37 @@ Categorías usadas: **Añadido**, **Cambiado**, **Corregido**, **Eliminado**.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.5.6] - 2026-09-05
13
+
14
+ ### Corregido
15
+ - La prima de las derivadas se anidaba un nivel en cada conversión de ida y
16
+ vuelta: `f^{'}` se convertía en `f^{^{'}}`, y repitiendo el ciclo seguía
17
+ acumulando capas. La causa es que temml escribe la prima como un superíndice
18
+ sobre una base vacía, que al volver de MathML se traducía literalmente. Solo
19
+ se colapsa esa forma: un superíndice doble real, que sí tiene base, no se
20
+ toca.
21
+ - Las dependencias del repositorio estaban ancladas a versiones anteriores a
22
+ las que instalaba cualquier usuario del paquete (`mathml-to-latex` 1.5.0
23
+ frente a 1.8.0), de modo que las pruebas no ejercitaban el mismo código que
24
+ se ejecuta al usarlo. Con ambas al día, un proyecto con 22 fórmulas
25
+ sobrevive tres ciclos `.elpx → .docx → .elpx` sin una sola diferencia.
26
+
27
+ ## [0.5.5] - 2026-09-05
28
+
29
+ ### Añadido
30
+ - `execonvert inspect` muestra la versión de eXeLearning con la que se creó el
31
+ proyecto, y la incluye en su salida `--json` como `exeVersion`.
32
+ - Aviso al convertir un `.elpx` creado con una versión de eXeLearning más
33
+ reciente que la que incorpora eXeConvert: la conversión sigue adelante, pero
34
+ puede no reconocer los elementos nuevos. Los proyectos de versiones iguales o
35
+ anteriores no muestran nada, porque funcionan.
36
+
37
+ ### Corregido
38
+ - Los `.elpx` generados declaraban `exe_version` 3.0, un valor heredado de la
39
+ plantilla, cuando el runtime incorporado es el 4.0.3. Ahora declaran la
40
+ versión real, que es la que escribe eXeLearning en sus propios archivos. El
41
+ campo es informativo: nada en el runtime lo usa para tomar decisiones.
42
+
12
43
  ## [0.5.4] - 2026-09-05
13
44
 
14
45
  ### Corregido
@@ -3,16 +3,17 @@ import { globSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { stderr, stdout } from 'node:process';
5
5
  import { createI18n } from '../src/i18n.js';
6
- import { buildPrintableHtmlDocument, buildPdfBlobFromPrintableHtml, convertElpxToDocx, convertElpxToHtml, convertHtmlToDocxResult, inspectElpxPages, } from '../src/converter.js';
6
+ import { buildPrintableHtmlDocument, buildPdfBlobFromPrintableHtml, convertElpxToDocx, convertElpxToHtml, convertHtmlToDocxResult, inspectElpx, inspectElpxPages, } from '../src/converter.js';
7
7
  import { convertDocxToElpx } from '../src/docx-import.js';
8
8
  import { convertElpxToMarkdown } from '../src/elpx-markdown.js';
9
9
  import { convertElpToElpx } from '../src/legacy-elp.js';
10
10
  import { convertMarkdownToElpx } from '../src/markdown-import.js';
11
11
  import { installCliRuntime } from './runtime.js';
12
+ import { EXE_RUNTIME_VERSION, isNewerThanRuntime } from '../src/exe-runtime.js';
12
13
  import { automaticCheck, autoCheckAllowed, runUpdate, updateText } from './updates.js';
13
- const CLI_VERSION = '0.5.4'.startsWith('__')
14
+ const CLI_VERSION = '0.5.6'.startsWith('__')
14
15
  ? String(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version)
15
- : '0.5.4';
16
+ : '0.5.6';
16
17
  const cliMessages = {
17
18
  es: {
18
19
  'help.title': 'CLI de eXeConvert',
@@ -32,6 +33,7 @@ const cliMessages = {
32
33
  'help.output.markdown': 'desde .md/.txt: .elpx',
33
34
  'help.option.to': 'Formato de salida para conversión múltiple (elpx, docx, md, pdf). Los archivos no compatibles se ignoran.',
34
35
  'warn.skippedInput': 'Aviso: se ignora {file}: no se puede convertir a {format}.',
36
+ 'warn.newerProject': 'Aviso: el proyecto se creó con eXeLearning {version} y esta versión incorpora la {runtime}. La conversión puede no reconocer los elementos nuevos.',
35
37
  'help.option.outDir': 'Directorio de salida (por defecto: mismo directorio que cada entrada)',
36
38
  'help.option.json': 'Imprime JSON legible por máquina',
37
39
  'help.option.pages': 'Exporta solo las referencias de página seleccionadas de un .elpx',
@@ -81,6 +83,7 @@ const cliMessages = {
81
83
  'help.output.markdown': 'des de .md/.txt: .elpx',
82
84
  'help.option.to': 'Format de sortida per a conversió múltiple (elpx, docx, md, pdf). Els fitxers no compatibles s’ignoren.',
83
85
  'warn.skippedInput': 'Avís: s’ignora {file}: no es pot convertir a {format}.',
86
+ 'warn.newerProject': 'Avís: el projecte s’ha creat amb eXeLearning {version} i aquesta versió incorpora la {runtime}. La conversió pot no reconèixer els elements nous.',
84
87
  'help.option.outDir': 'Directori de sortida (per defecte: mateix directori que cada entrada)',
85
88
  'help.option.json': 'Imprimeix JSON llegible per màquines',
86
89
  'help.option.pages': 'Exporta només les referències de pàgina seleccionades d’un .elpx',
@@ -130,6 +133,7 @@ const cliMessages = {
130
133
  'help.output.markdown': 'from .md/.txt: .elpx',
131
134
  'help.option.to': 'Output format for batch conversion (elpx, docx, md, pdf). Incompatible files are skipped.',
132
135
  'warn.skippedInput': 'Warning: skipping {file}: it cannot be converted to {format}.',
136
+ 'warn.newerProject': 'Warning: this project was created with eXeLearning {version} and this build carries {runtime}. The conversion may not recognise its newer elements.',
133
137
  'help.option.outDir': 'Output directory (default: same directory as each input)',
134
138
  'help.option.json': 'Print machine-readable JSON',
135
139
  'help.option.pages': 'Export only selected page refs from an .elpx input',
@@ -548,11 +552,15 @@ function printInspectText(pages) {
548
552
  async function runInspect(inputPath, json) {
549
553
  installCliRuntime();
550
554
  const inputFile = await readInputFile(inputPath, buildMime('elpx'));
551
- const pages = buildPageRefs(await inspectElpxPages(inputFile));
555
+ const inspection = await inspectElpx(inputFile);
556
+ const pages = buildPageRefs(inspection.pages);
552
557
  if (json) {
553
- stdout.write(`${JSON.stringify({ input: resolve(inputPath), pages }, null, 2)}\n`);
558
+ stdout.write(`${JSON.stringify({ input: resolve(inputPath), exeVersion: inspection.exeVersion, pages }, null, 2)}\n`);
554
559
  return;
555
560
  }
561
+ if (inspection.exeVersion) {
562
+ stdout.write(`eXeLearning: ${inspection.exeVersion}\n`);
563
+ }
556
564
  printInspectText(pages);
557
565
  }
558
566
  async function runConvert(args) {
@@ -563,6 +571,9 @@ async function runConvert(args) {
563
571
  const inputFormat = detectFormat(inputPath, i18n.t);
564
572
  const outputFormat = detectFormat(outputPath, i18n.t);
565
573
  const inputFile = await readInputFile(inputPath, buildMime(inputFormat));
574
+ if (inputFormat === 'elpx' && !args.json) {
575
+ await warnIfNewerThanRuntime(inputFile, i18n.t);
576
+ }
566
577
  const selectedPageIds = inputFormat === 'elpx'
567
578
  ? await loadSelectedPageIds(inputFile, args.pages, args.pageIds)
568
579
  : [];
@@ -713,6 +724,21 @@ async function runConvert(args) {
713
724
  }
714
725
  throw new Error(i18n.t('error.unsupportedConversion', { input: inputFormat, output: outputFormat }));
715
726
  }
727
+ // Only newer projects are worth a warning: older ones convert fine, as every
728
+ // release from 4.0.0 on has shown. A project from a release this build does not
729
+ // know may carry elements the conversion drops without saying anything.
730
+ async function warnIfNewerThanRuntime(inputFile, t) {
731
+ try {
732
+ const { exeVersion } = await inspectElpx(inputFile);
733
+ if (isNewerThanRuntime(exeVersion)) {
734
+ stderr.write(`${t('warn.newerProject', { version: exeVersion, runtime: EXE_RUNTIME_VERSION })}\n`);
735
+ }
736
+ }
737
+ catch {
738
+ // Inspecting is only for the warning: a project that cannot be read here
739
+ // will fail with its own message further down.
740
+ }
741
+ }
716
742
  async function loadSelectedPageIds(inputFile, pageRefs, pageIds) {
717
743
  if (pageRefs.length === 0 && pageIds.length === 0) {
718
744
  return [];
@@ -1830,15 +1830,26 @@ function ensurePdfMakeFonts() {
1830
1830
  pdfMakeInitialized = true;
1831
1831
  }
1832
1832
  export async function inspectElpxPages(file) {
1833
+ return (await inspectElpx(file)).pages;
1834
+ }
1835
+ /**
1836
+ * Reads the pages and the eXeLearning release the project declares. The version
1837
+ * is what tells a caller whether the project comes from a release newer than the
1838
+ * vendored runtime, in which case the conversion may not know every element.
1839
+ */
1840
+ export async function inspectElpx(file) {
1833
1841
  const input = new Uint8Array(await file.arrayBuffer());
1834
1842
  const entries = unzipSync(input);
1835
1843
  const project = parseProject(entries);
1836
- return project.pages.map(page => ({
1837
- id: page.id,
1838
- parentId: page.parentId,
1839
- title: page.title,
1840
- depth: page.depth,
1841
- }));
1844
+ return {
1845
+ exeVersion: project.exeVersion,
1846
+ pages: project.pages.map(page => ({
1847
+ id: page.id,
1848
+ parentId: page.parentId,
1849
+ title: page.title,
1850
+ depth: page.depth,
1851
+ })),
1852
+ };
1842
1853
  }
1843
1854
  async function buildCompatibleDocx(htmlDocument) {
1844
1855
  const htmlDoc = new DOMParser().parseFromString(htmlDocument, 'text/html');
@@ -1958,6 +1969,7 @@ function parseProject(entries) {
1958
1969
  if (parserError) {
1959
1970
  throw new Error('El content.xml no se ha podido interpretar correctamente.');
1960
1971
  }
1972
+ const exeVersion = findResourceValue(xmlDoc, 'exe_version');
1961
1973
  const title = findPropertyValue(xmlDoc, 'pp_title') || 'eXeLearning';
1962
1974
  const subtitle = findPropertyValue(xmlDoc, 'pp_subtitle') || '';
1963
1975
  const language = findPropertyValue(xmlDoc, 'pp_lang') || 'es';
@@ -1969,6 +1981,7 @@ function parseProject(entries) {
1969
1981
  title,
1970
1982
  subtitle,
1971
1983
  language,
1984
+ exeVersion,
1972
1985
  pages: sortPagesHierarchically(pages),
1973
1986
  };
1974
1987
  }
@@ -4953,6 +4966,17 @@ function toDocxHeadingLevel(level) {
4953
4966
  return HeadingLevel.HEADING_6;
4954
4967
  }
4955
4968
  }
4969
+ // eXeLearning records the release that wrote the project as an odeResource.
4970
+ // It is descriptive only -- nothing in the runtime reads it to decide anything.
4971
+ function findResourceValue(xmlDoc, key) {
4972
+ const nodes = Array.from(xmlDoc.getElementsByTagName('odeResource'));
4973
+ for (const node of nodes) {
4974
+ if (getDirectText(node, 'key') === key) {
4975
+ return getDirectText(node, 'value');
4976
+ }
4977
+ }
4978
+ return null;
4979
+ }
4956
4980
  function findPropertyValue(xmlDoc, key) {
4957
4981
  const nodes = Array.from(xmlDoc.getElementsByTagName('odeProperty'));
4958
4982
  for (const node of nodes) {
@@ -2,6 +2,7 @@ import { unzipSync, zipSync } from 'fflate';
2
2
  import mammoth from 'mammoth';
3
3
  import { MathMLToLaTeX } from 'mathml-to-latex';
4
4
  import { DOMParser as XmldomParser, XMLSerializer as XmldomSerializer } from '@xmldom/xmldom';
5
+ import { EXE_RUNTIME_VERSION } from './exe-runtime.js';
5
6
  // @ts-expect-error the vendored omml2mathml ships no TypeScript declarations.
6
7
  import omml2mathml from './vendor/omml2mathml/index.js';
7
8
  import temml from 'temml';
@@ -636,7 +637,7 @@ function generateContentXml(project) {
636
637
  <odeResources>
637
638
  <odeResource><key>odeId</key><value>${escapeXml(odeId)}</value></odeResource>
638
639
  <odeResource><key>odeVersionId</key><value>${escapeXml(odeVersionId)}</value></odeResource>
639
- <odeResource><key>exe_version</key><value>3.0</value></odeResource>
640
+ <odeResource><key>exe_version</key><value>${EXE_RUNTIME_VERSION}</value></odeResource>
640
641
  </odeResources>
641
642
  <odeProperties>
642
643
  <odeProperty><key>pp_title</key><value>${escapeXml(project.title || 'Documento importado')}</value></odeProperty>
@@ -1015,6 +1016,10 @@ function stripHtmlFromMath(value) {
1015
1016
  const document = new DOMParser().parseFromString(`<!doctype html><html><body>${withLineBreaks}</body></html>`, 'text/html');
1016
1017
  return document.body.textContent || '';
1017
1018
  }
1019
+ /** Exposed for tests: the LaTeX normalisation applied to every converted formula. */
1020
+ export function normalizeLatexForTests(value) {
1021
+ return normalizeLatexValue(value);
1022
+ }
1018
1023
  function normalizeLatexValue(value) {
1019
1024
  let output = value.normalize('NFC').replace(/\u00a0/g, ' ').replace(/\r/g, '');
1020
1025
  output = output.replace(/[\uFFFD\uFEFF\u00AD\u2066-\u2069\u200B-\u200F\u202A-\u202E\uFFF9-\uFFFB]/g, '');
@@ -1027,6 +1032,11 @@ function normalizeLatexValue(value) {
1027
1032
  output = output.replace(/(^|[^\\])\.{2}\s+\\\\/g, '$1\\ldots ');
1028
1033
  output = output.replace(/\\\\backslash\b/g, '\\\\');
1029
1034
  output = output.replace(/\\backslash\b/g, '\\\\');
1035
+ // temml writes a prime as a superscript with an empty base, so a round trip
1036
+ // through MathML turns f^{'} into f^{^{'}} and nests one level deeper every
1037
+ // time. Only the empty-base form is collapsed: a real double superscript
1038
+ // carries a base and is left alone.
1039
+ output = output.replace(/\^\{\s*\^\{\s*([\u2032\u2033\u2034'`]+)\s*\}\s*\}/g, "^{$1}");
1030
1040
  output = output.replace(/[ \t]+/g, ' ');
1031
1041
  output = output.replace(/ ?\n ?/g, '\n');
1032
1042
  output = output.trim();
@@ -0,0 +1,29 @@
1
+ // The eXeLearning release whose runtime is vendored in this repository, kept in
2
+ // step with app/public/exelearning/runtime-source.json by
3
+ // tests/runtime-version.test.mjs, which npm run sync:exe updates.
4
+ export const EXE_RUNTIME_VERSION = '4.0.3';
5
+ /**
6
+ * Compares two eXeLearning versions. Returns true when `version` is newer than
7
+ * the vendored runtime, which is the only case worth warning about: a project
8
+ * from an older release converts fine, one from a newer release may carry
9
+ * elements this code does not know about and would drop without saying so.
10
+ */
11
+ export function isNewerThanRuntime(version) {
12
+ if (!version) {
13
+ return false;
14
+ }
15
+ const parse = (value) => value.trim().replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10));
16
+ const candidate = parse(version);
17
+ const runtime = parse(EXE_RUNTIME_VERSION);
18
+ if (candidate.some(Number.isNaN)) {
19
+ return false;
20
+ }
21
+ for (let index = 0; index < Math.max(candidate.length, runtime.length); index += 1) {
22
+ const left = candidate[index] ?? 0;
23
+ const right = runtime[index] ?? 0;
24
+ if (left !== right) {
25
+ return left > right;
26
+ }
27
+ }
28
+ return false;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "execonvert",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,9 @@
40
40
  "test:runtime": "node scripts/test-runtime.mjs",
41
41
  "test:cli": "node --test tests/cli-update.test.mjs",
42
42
  "test:docx": "node --test tests/docx-omml.test.mjs",
43
- "test:args": "node --test tests/cli-args.test.mjs"
43
+ "test:args": "node --test tests/cli-args.test.mjs",
44
+ "test:version": "node --import tsx --test tests/runtime-version.test.mjs",
45
+ "test:primes": "node --import tsx --test tests/latex-primes.test.mjs"
44
46
  },
45
47
  "dependencies": {
46
48
  "@resvg/resvg-js": "^2.6.2",