sygal-cli 0.3.1 → 0.3.5

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,21 @@ partagent toujours le même numéro de version, publiés ensemble, même si un
9
9
  seul a réellement changé de contenu — même principe que l'app desktop.
10
10
  Détails du process : `CLI_RELEASE_GUIDE.md` à la racine du repo.
11
11
 
12
+ ## 0.3.1
13
+
14
+ - **Corrige la 0.3.0**, publiée avec un bundle `sygal-cli` (dist/index.js)
15
+ périmé — construit avant les changements ci-dessous, donc `.rtf` restait
16
+ "Extension non supportée" et aucun des correctifs OCR n'était réellement
17
+ présent malgré le numéro de version. Ajout d'un hook `prepublishOnly`
18
+ (rebuild automatique avant toute publication) pour que ça ne puisse plus
19
+ se reproduire. **Si vous avez installé la 0.3.0, mettez à jour vers la
20
+ 0.3.1.**
21
+ - Nouveau : support des formats **AVIF** et **RTF** (texte brut, tableaux
22
+ RTF rendus lisibles ligne par ligne).
23
+ - Fix : messages d'erreur clairs pour HEIC/HEIF (HEVC non pris en charge),
24
+ AVIF "séquence"/rafale, et WebP envoyé au fournisseur NVIDIA — au lieu
25
+ d'erreurs génériques ou d'un faux "erreur réseau".
26
+
12
27
  ## 0.2.1
13
28
 
14
29
  - Notice de mise à jour après chaque conversion (terminal interactif
package/dist/index.js CHANGED
@@ -71,6 +71,10 @@ var SUPPORTED_EXTENSIONS = {
71
71
  heif: "image/heif",
72
72
  avif: "image/avif"
73
73
  };
74
+ var PACKAGE_DIR_EXTENSIONS = /* @__PURE__ */ new Set(["epub"]);
75
+ function isPackageDirExtension(extension) {
76
+ return PACKAGE_DIR_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ""));
77
+ }
74
78
  function mimeForExtension(extension) {
75
79
  return SUPPORTED_EXTENSIONS[extension.toLowerCase()] ?? null;
76
80
  }
@@ -121,10 +125,16 @@ var OCR_MODELS = {
121
125
  openai: "gpt-4o",
122
126
  mistral: "mistral-ocr-latest",
123
127
  claude: "claude-sonnet-4-6",
124
- // Bascule 18/07/2026 (test comparatif, validation Cyrill) : nemotron nano VL
125
- // remplace meta/llama-3.2-11b-vision-instruct 0/5 confabulation sur portrait
126
- // sans texte (contre 5/5), français fidèle, plus rapide et déterministe.
127
- nvidia: "nvidia/llama-3.1-nemotron-nano-vl-8b-v1",
128
+ // Bascule 27/07/2026 (banc comparatif sur images réelles, validation Cyrill) :
129
+ // nemotron-nano-12b-v2-vl remplace llama-3.1-nemotron-nano-vl-8b-v1. Sur une
130
+ // capture de dossier de 22 lignes, le 8b s'arrêtait au premier en-tête (« Nom »,
131
+ // 1 token) 3 fois sur 3 ; le 12b la transcrit intégralement 3 fois sur 3, accents
132
+ // préservés. Il respecte aussi la consigne de langue sur une image sans texte
133
+ // (description en français, là où le 8b répondait en anglais), sans confabuler.
134
+ // Entraîné sur DocLayNet / PubTables-1M / PDF CommonCrawl — OCRBench 85,6.
135
+ // Précédemment : llama-3.2-11b-vision-instruct, écarté le 18/07/2026 (5/5
136
+ // confabulations sur un portrait sans texte).
137
+ nvidia: "nvidia/nemotron-nano-12b-v2-vl",
128
138
  gemini: "gemini-2.0-flash"
129
139
  };
130
140
  var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
@@ -410,7 +420,7 @@ var LOCALE_LANGUAGE = {
410
420
  es: "Spanish"
411
421
  };
412
422
  function buildOcrPrompt(labelLanguage = "French") {
413
- return `Transcribe ALL visible text in this image verbatim as clean Markdown, preserving the document layout: headings, lists and tables exactly as shown. Transcribe EVERY line, row and table cell faithfully \u2014 never summarize, skip, merge, reorder, or invent rows, values, or text. If a cell is empty, leave it empty. Keep the document text in its ORIGINAL language exactly as written \u2014 do not translate it. Do NOT invent a table of contents, section titles, or any heading/structure that is not visually present in the document. If you must add a label of your own that is not in the document, write it in ${labelLanguage}. While transcribing, do NOT describe the scene, the people, or any visual elements that are not text. ONLY IF the image contains no readable text at all: instead of an empty document, describe the image in ${labelLanguage} as clean Markdown \u2014 one short paragraph covering the subject, the setting and the dominant colors, factual and concise, without inventing any text that is not visible. When describing, NEVER invent or guess names, ages, occupations, employers, biographies, locations, dates, or any information about people or organizations. Describe only what is physically visible in the image; if something is not visible, do not mention it. Output only the Markdown.`;
423
+ return `Transcribe ALL visible text in this image verbatim as clean Markdown, preserving the document layout: headings, lists and tables exactly as shown. Render every table as a GitHub-flavored Markdown table using pipes (| \u2026 | \u2026 |) with a header separator row \u2014 NEVER output LaTeX, never use \\begin{tabular}, \\multicolumn or any TeX command. Transcribe EVERY line, row and table cell faithfully \u2014 never summarize, skip, merge, reorder, or invent rows, values, or text. If a cell is empty, leave it empty. Keep the document text in its ORIGINAL language exactly as written \u2014 do not translate it. Do NOT invent a table of contents, section titles, or any heading/structure that is not visually present in the document. If you must add a label of your own that is not in the document, write it in ${labelLanguage}. While transcribing, do NOT describe the scene, the people, or any visual elements that are not text. ONLY IF the image contains no readable text at all: instead of an empty document, describe the image in ${labelLanguage} as clean Markdown \u2014 one short paragraph covering the subject, the setting and the dominant colors, factual and concise, without inventing any text that is not visible. When describing, NEVER invent or guess names, ages, occupations, employers, biographies, locations, dates, or any information about people or organizations. Describe only what is physically visible in the image; if something is not visible, do not mention it. Output only the Markdown.`;
414
424
  }
415
425
  function buildTranslatePrompt(targetLocale) {
416
426
  const language = LOCALE_LANGUAGE[targetLocale] ?? "English";
@@ -668,6 +678,30 @@ var mistralProvider = {
668
678
  }
669
679
  };
670
680
 
681
+ // ../core/src/ocr/latex-tables.ts
682
+ var TABULAR_RE = /\\begin\{tabular\}(?:\{[^}]*\})?([\s\S]*?)\\end\{tabular\}/g;
683
+ function cleanCell(cell) {
684
+ return cell.replace(/\\multicolumn\{\d+\}\{[^}]*\}\{([\s\S]*?)\}/g, "$1").replace(/\\multirow\{\d+\}\{[^}]*\}\{([\s\S]*?)\}/g, "$1").replace(/\\textbf\{([\s\S]*?)\}/g, "**$1**").replace(/\\textit\{([\s\S]*?)\}/g, "*$1*").replace(/\\(?:hline|toprule|midrule|bottomrule)\b/g, "").replace(/\\\\/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
685
+ }
686
+ function tabularToMarkdown(body) {
687
+ const rows = body.split(/\\\\/).map((row) => row.trim()).filter((row) => row.replace(/\\(?:hline|toprule|midrule|bottomrule)\b/g, "").trim().length > 0).map((row) => row.split("&").map(cleanCell)).filter((cells) => cells.some((cell) => cell.length > 0));
688
+ if (rows.length === 0) return null;
689
+ const columns = Math.max(...rows.map((row) => row.length));
690
+ if (columns < 2) return rows.map((row) => row[0]).join("\n\n");
691
+ const pad = (cells) => `| ${[...cells, ...Array(columns - cells.length).fill("")].join(" | ")} |`;
692
+ const [header, ...body_] = rows;
693
+ return [pad(header), `| ${Array(columns).fill("---").join(" | ")} |`, ...body_.map(pad)].join(
694
+ "\n"
695
+ );
696
+ }
697
+ function convertLatexTables(markdown) {
698
+ if (!markdown.includes("\\begin{tabular}")) return markdown;
699
+ return markdown.replace(TABULAR_RE, (whole, body) => {
700
+ const converted = tabularToMarkdown(body);
701
+ return converted ?? whole;
702
+ });
703
+ }
704
+
671
705
  // ../core/src/ocr/nvidia-provider.ts
672
706
  var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
673
707
  var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
@@ -812,6 +846,28 @@ function collapseRepeatedLines(markdown) {
812
846
  }
813
847
  return kept.join("\n");
814
848
  }
849
+ var TRUNCATED_COMPLETION_TOKENS = 3;
850
+ function completionTokensOf(raw) {
851
+ if (typeof raw !== "object" || raw === null) return null;
852
+ const usage = raw.usage;
853
+ if (typeof usage !== "object" || usage === null) return null;
854
+ const value = usage.completion_tokens ?? usage.output_tokens;
855
+ return typeof value === "number" ? value : null;
856
+ }
857
+ function looksTruncated(result) {
858
+ const tokens = completionTokensOf(result.raw);
859
+ return tokens !== null && tokens <= TRUNCATED_COMPLETION_TOKENS;
860
+ }
861
+ function stripEnclosingCodeFence(markdown) {
862
+ const trimmed = markdown.trim();
863
+ const opening = /^```([a-zA-Z]*)\r?\n?/.exec(trimmed);
864
+ if (!opening || !trimmed.endsWith("```") || trimmed.length < 6) return markdown;
865
+ const language = opening[1].toLowerCase();
866
+ if (language && language !== "markdown" && language !== "md") return markdown;
867
+ const inner = trimmed.slice(opening[0].length, -3);
868
+ if (inner.includes("```")) return markdown;
869
+ return inner.trim();
870
+ }
815
871
  var REFUSAL_RE = /^(i['’]?m sorry|i cannot|i can['’]?t|i am (unable|not able)|sorry,|je suis désolé|désolé,|je ne peux pas|lo siento|mi dispiace|es tut mir leid|ich kann)/i;
816
872
  function isProviderRefusal(markdown) {
817
873
  return REFUSAL_RE.test(markdown.trim().slice(0, 80));
@@ -885,13 +941,23 @@ var ApiOcrRunner = class {
885
941
  if (!apiKey2) {
886
942
  throw new ConversionError("API_KEY_MISSING", `Aucune cl\xE9 API pour ${custom.name}`);
887
943
  }
888
- const result2 = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
944
+ let result2 = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
945
+ if (looksTruncated(result2) && !isProviderRefusal(result2.markdown)) {
946
+ const second = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
947
+ if (second.markdown.trim().length > result2.markdown.trim().length) result2 = second;
948
+ }
889
949
  if (isProviderRefusal(result2.markdown)) {
890
950
  throw new ConversionError("OCR_REFUSED", `${custom.name} : refus du mod\xE8le`);
891
951
  }
892
952
  return {
893
- markdown: collapseRepeatedLines(result2.markdown),
894
- meta: { provider: custom.name, model: custom.model }
953
+ markdown: convertLatexTables(
954
+ stripEnclosingCodeFence(collapseRepeatedLines(result2.markdown))
955
+ ),
956
+ meta: {
957
+ provider: custom.name,
958
+ model: custom.model,
959
+ ...looksTruncated(result2) ? { truncated: true } : {}
960
+ }
895
961
  };
896
962
  }
897
963
  const providerId = settings.ocrProvider;
@@ -903,13 +969,24 @@ var ApiOcrRunner = class {
903
969
  `Aucune cl\xE9 API enregistr\xE9e pour le provider ${providerId}`
904
970
  );
905
971
  }
906
- const result = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
972
+ let result = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
973
+ if (looksTruncated(result) && !isProviderRefusal(result.markdown)) {
974
+ const second = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
975
+ if (second.markdown.trim().length > result.markdown.trim().length) result = second;
976
+ }
907
977
  if (isProviderRefusal(result.markdown)) {
908
978
  throw new ConversionError("OCR_REFUSED", `${provider.id} : refus du mod\xE8le`);
909
979
  }
910
980
  return {
911
- markdown: collapseRepeatedLines(result.markdown),
912
- meta: { provider: provider.id, model: provider.model }
981
+ markdown: convertLatexTables(stripEnclosingCodeFence(collapseRepeatedLines(result.markdown))),
982
+ meta: {
983
+ provider: provider.id,
984
+ model: provider.model,
985
+ // Rejeu compris, le modèle s'est arrêté au premier libellé : le
986
+ // ConversionService en fait un avertissement dans le journal plutôt
987
+ // que d'enregistrer silencieusement une transcription amputée.
988
+ ...looksTruncated(result) ? { truncated: true } : {}
989
+ }
913
990
  };
914
991
  }
915
992
  /** Tentative initiale + retries backoff sur erreurs retryable uniquement */
@@ -1212,7 +1289,7 @@ var TranslationRunner = class {
1212
1289
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1213
1290
  try {
1214
1291
  const result = await attempt(controller.signal);
1215
- return collapseRepeatedLines(result.markdown);
1292
+ return convertLatexTables(stripEnclosingCodeFence(collapseRepeatedLines(result.markdown)));
1216
1293
  } catch (err) {
1217
1294
  if (controller.signal.aborted) {
1218
1295
  throw new ConversionError("TIMEOUT", `${providerLabel} : d\xE9lai de traduction d\xE9pass\xE9`);
@@ -1667,7 +1744,7 @@ async function expandInputs(inputs) {
1667
1744
  });
1668
1745
  continue;
1669
1746
  }
1670
- if (stat.isDirectory()) {
1747
+ if (stat.isDirectory() && !isPackageDirExtension((0, import_node_path5.extname)(path).slice(1))) {
1671
1748
  await collectFromDir(path, jobs);
1672
1749
  } else {
1673
1750
  jobs.push({ input: path, kind: "file" });
@@ -1687,7 +1764,11 @@ async function collectFromDir(dir, jobs) {
1687
1764
  if (entry.name.startsWith(".")) continue;
1688
1765
  const full = (0, import_node_path5.join)(dir, entry.name);
1689
1766
  if (entry.isDirectory()) {
1690
- await collectFromDir(full, jobs);
1767
+ if (isPackageDirExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
1768
+ jobs.push({ input: full, kind: "file" });
1769
+ } else {
1770
+ await collectFromDir(full, jobs);
1771
+ }
1691
1772
  } else if (entry.isFile() && mimeForExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
1692
1773
  jobs.push({ input: full, kind: "file" });
1693
1774
  }
@@ -2700,7 +2781,7 @@ Exemples :
2700
2781
  let singleFile = false;
2701
2782
  if (paths.length === 1 && !/^https?:\/\//i.test(paths[0])) {
2702
2783
  try {
2703
- singleFile = (0, import_node_fs8.statSync)(paths[0]).isFile();
2784
+ singleFile = (0, import_node_fs8.statSync)(paths[0]).isFile() || isPackageDirExtension((0, import_node_path10.extname)(paths[0]).slice(1));
2704
2785
  } catch {
2705
2786
  singleFile = true;
2706
2787
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygal-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.5",
4
4
  "description": "Sygal CLI - convert files and web pages to Markdown/JSON (local MarkItDown pipeline, OCR for images)",
5
5
  "author": "Cyrill Semah",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -37,9 +37,9 @@
37
37
  "@sygal/core": "0.1.0"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@sygal/sidecar-darwin-arm64": "0.3.1",
41
- "@sygal/sidecar-win32-x64": "0.3.1",
42
- "@sygal/sidecar-linux-x64": "0.3.1"
40
+ "@sygal/sidecar-win32-x64": "0.3.5",
41
+ "@sygal/sidecar-darwin-arm64": "0.3.5",
42
+ "@sygal/sidecar-linux-x64": "0.3.5"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --external:sharp --external:gpt-tokenizer --external:commander --external:picocolors",