sygal-cli 0.3.0 → 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.
Files changed (3) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.js +123 -21
  3. package/package.json +4 -4
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
@@ -58,6 +58,7 @@ var SUPPORTED_EXTENSIONS = {
58
58
  eml: "message/rfc822",
59
59
  ipynb: "application/x-ipynb+json",
60
60
  zip: "application/zip",
61
+ rtf: "application/rtf",
61
62
  // OCR (images) — HEIC/TIFF normalized to PNG before the API call (sharp)
62
63
  png: "image/png",
63
64
  jpg: "image/jpeg",
@@ -67,8 +68,13 @@ var SUPPORTED_EXTENSIONS = {
67
68
  tiff: "image/tiff",
68
69
  tif: "image/tiff",
69
70
  heic: "image/heic",
70
- heif: "image/heif"
71
+ heif: "image/heif",
72
+ avif: "image/avif"
71
73
  };
74
+ var PACKAGE_DIR_EXTENSIONS = /* @__PURE__ */ new Set(["epub"]);
75
+ function isPackageDirExtension(extension) {
76
+ return PACKAGE_DIR_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ""));
77
+ }
72
78
  function mimeForExtension(extension) {
73
79
  return SUPPORTED_EXTENSIONS[extension.toLowerCase()] ?? null;
74
80
  }
@@ -89,7 +95,7 @@ function countTokens(text) {
89
95
 
90
96
  // ../core/src/image-normalize.ts
91
97
  var import_sharp = __toESM(require("sharp"));
92
- var NEEDS_PNG = /* @__PURE__ */ new Set([".heic", ".heif", ".tiff", ".tif"]);
98
+ var NEEDS_PNG = /* @__PURE__ */ new Set([".heic", ".heif", ".tiff", ".tif", ".avif"]);
93
99
  function needsPngNormalization(ext) {
94
100
  return NEEDS_PNG.has(ext.toLowerCase());
95
101
  }
@@ -119,10 +125,16 @@ var OCR_MODELS = {
119
125
  openai: "gpt-4o",
120
126
  mistral: "mistral-ocr-latest",
121
127
  claude: "claude-sonnet-4-6",
122
- // Bascule 18/07/2026 (test comparatif, validation Cyrill) : nemotron nano VL
123
- // remplace meta/llama-3.2-11b-vision-instruct 0/5 confabulation sur portrait
124
- // sans texte (contre 5/5), français fidèle, plus rapide et déterministe.
125
- 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",
126
138
  gemini: "gemini-2.0-flash"
127
139
  };
128
140
  var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
@@ -219,7 +231,9 @@ var SidecarManager = class {
219
231
  const response = await this.request({ id: (0, import_node_crypto.randomUUID)(), cmd: "pdf_probe", path });
220
232
  return {
221
233
  pageCount: response.page_count ?? 0,
222
- pagesTextLength: response.pages_text_length ?? []
234
+ pagesTextLength: response.pages_text_length ?? [],
235
+ pageWidth: response.page_width ?? void 0,
236
+ pageHeight: response.page_height ?? void 0
223
237
  };
224
238
  }
225
239
  async renderPdfPage(path, page) {
@@ -406,7 +420,7 @@ var LOCALE_LANGUAGE = {
406
420
  es: "Spanish"
407
421
  };
408
422
  function buildOcrPrompt(labelLanguage = "French") {
409
- 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.`;
410
424
  }
411
425
  function buildTranslatePrompt(targetLocale) {
412
426
  const language = LOCALE_LANGUAGE[targetLocale] ?? "English";
@@ -664,6 +678,30 @@ var mistralProvider = {
664
678
  }
665
679
  };
666
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
+
667
705
  // ../core/src/ocr/nvidia-provider.ts
668
706
  var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
669
707
  var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
@@ -674,7 +712,7 @@ var nvidiaProvider = {
674
712
  async convertImage(img, mime, apiKey, signal, prompt) {
675
713
  let payloadImg = img;
676
714
  let payloadMime = mime;
677
- if (payloadImg.length > NVIDIA_MAX_IMAGE_BYTES) {
715
+ if (payloadMime === "image/webp" || payloadImg.length > NVIDIA_MAX_IMAGE_BYTES) {
678
716
  payloadImg = await shrinkToMaxBytes(payloadImg, NVIDIA_MAX_IMAGE_BYTES);
679
717
  payloadMime = "image/jpeg";
680
718
  }
@@ -808,6 +846,28 @@ function collapseRepeatedLines(markdown) {
808
846
  }
809
847
  return kept.join("\n");
810
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
+ }
811
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;
812
872
  function isProviderRefusal(markdown) {
813
873
  return REFUSAL_RE.test(markdown.trim().slice(0, 80));
@@ -829,7 +889,8 @@ var IMAGE_MIME = {
829
889
  ".tiff": "image/tiff",
830
890
  ".tif": "image/tiff",
831
891
  ".heic": "image/heic",
832
- ".heif": "image/heif"
892
+ ".heif": "image/heif",
893
+ ".avif": "image/avif"
833
894
  };
834
895
  var ApiOcrRunner = class {
835
896
  constructor(settings, options = {}) {
@@ -850,7 +911,21 @@ var ApiOcrRunner = class {
850
911
  let img = await (0, import_promises.readFile)(sourcePath);
851
912
  let sendMime = mime;
852
913
  if (needsPngNormalization(ext)) {
853
- img = await toPngBuffer(img);
914
+ try {
915
+ img = await toPngBuffer(img);
916
+ } catch (err) {
917
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
918
+ if (ext === ".heic" || ext === ".heif") {
919
+ throw new ConversionError(
920
+ "HEIC_HEVC_UNSUPPORTED",
921
+ `D\xE9codage ${ext.slice(1).toUpperCase()} (codec HEVC) impossible : ${detail}`
922
+ );
923
+ }
924
+ throw new ConversionError(
925
+ "IMAGE_DECODE_UNSUPPORTED",
926
+ `D\xE9codage ${ext.slice(1).toUpperCase()} impossible (format ou variante non pris en charge) : ${detail}`
927
+ );
928
+ }
854
929
  sendMime = "image/png";
855
930
  }
856
931
  if (settings.customProviderId) {
@@ -866,13 +941,23 @@ var ApiOcrRunner = class {
866
941
  if (!apiKey2) {
867
942
  throw new ConversionError("API_KEY_MISSING", `Aucune cl\xE9 API pour ${custom.name}`);
868
943
  }
869
- 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
+ }
870
949
  if (isProviderRefusal(result2.markdown)) {
871
950
  throw new ConversionError("OCR_REFUSED", `${custom.name} : refus du mod\xE8le`);
872
951
  }
873
952
  return {
874
- markdown: collapseRepeatedLines(result2.markdown),
875
- 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
+ }
876
961
  };
877
962
  }
878
963
  const providerId = settings.ocrProvider;
@@ -884,13 +969,24 @@ var ApiOcrRunner = class {
884
969
  `Aucune cl\xE9 API enregistr\xE9e pour le provider ${providerId}`
885
970
  );
886
971
  }
887
- 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
+ }
888
977
  if (isProviderRefusal(result.markdown)) {
889
978
  throw new ConversionError("OCR_REFUSED", `${provider.id} : refus du mod\xE8le`);
890
979
  }
891
980
  return {
892
- markdown: collapseRepeatedLines(result.markdown),
893
- 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
+ }
894
990
  };
895
991
  }
896
992
  /** Tentative initiale + retries backoff sur erreurs retryable uniquement */
@@ -1193,7 +1289,7 @@ var TranslationRunner = class {
1193
1289
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1194
1290
  try {
1195
1291
  const result = await attempt(controller.signal);
1196
- return collapseRepeatedLines(result.markdown);
1292
+ return convertLatexTables(stripEnclosingCodeFence(collapseRepeatedLines(result.markdown)));
1197
1293
  } catch (err) {
1198
1294
  if (controller.signal.aborted) {
1199
1295
  throw new ConversionError("TIMEOUT", `${providerLabel} : d\xE9lai de traduction d\xE9pass\xE9`);
@@ -1550,6 +1646,8 @@ var CONVERSION_CODE_MAP = {
1550
1646
  OCR_REFUSED: "ocr_refused",
1551
1647
  URL_FORBIDDEN: "url_forbidden",
1552
1648
  URL_UNREACHABLE: "url_unreachable",
1649
+ HEIC_HEVC_UNSUPPORTED: "heic_hevc_unsupported",
1650
+ IMAGE_DECODE_UNSUPPORTED: "image_decode_unsupported",
1553
1651
  UNKNOWN: "unknown"
1554
1652
  };
1555
1653
  var CliCodedError = class extends Error {
@@ -1646,7 +1744,7 @@ async function expandInputs(inputs) {
1646
1744
  });
1647
1745
  continue;
1648
1746
  }
1649
- if (stat.isDirectory()) {
1747
+ if (stat.isDirectory() && !isPackageDirExtension((0, import_node_path5.extname)(path).slice(1))) {
1650
1748
  await collectFromDir(path, jobs);
1651
1749
  } else {
1652
1750
  jobs.push({ input: path, kind: "file" });
@@ -1666,7 +1764,11 @@ async function collectFromDir(dir, jobs) {
1666
1764
  if (entry.name.startsWith(".")) continue;
1667
1765
  const full = (0, import_node_path5.join)(dir, entry.name);
1668
1766
  if (entry.isDirectory()) {
1669
- 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
+ }
1670
1772
  } else if (entry.isFile() && mimeForExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
1671
1773
  jobs.push({ input: full, kind: "file" });
1672
1774
  }
@@ -2679,7 +2781,7 @@ Exemples :
2679
2781
  let singleFile = false;
2680
2782
  if (paths.length === 1 && !/^https?:\/\//i.test(paths[0])) {
2681
2783
  try {
2682
- 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));
2683
2785
  } catch {
2684
2786
  singleFile = true;
2685
2787
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygal-cli",
3
- "version": "0.3.0",
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-linux-x64": "0.3.0",
41
- "@sygal/sidecar-darwin-arm64": "0.3.0",
42
- "@sygal/sidecar-win32-x64": "0.3.0"
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",