sygal-cli 0.5.3 → 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.
Files changed (3) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/dist/index.js +141 -69
  3. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -9,10 +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.5.2
12
+ ## 0.5.5
13
+
14
+ - Fix : exporter un document dont le nom contient des caractères illégaux sur Windows/macOS
15
+ (`/`, `:`, etc.) — fréquent dans les titres de pages web importées — ne plante plus en
16
+ `ENOENT`. Les noms trop longs (> 255 octets UTF-8, ex. titres avec emojis) sont désormais
17
+ tronqués sur une frontière de code point plutôt que de planter en `ENAMETOOLONG`.
18
+ - Fix : convertir une URL dont le fetch réussit (200) mais dont le contenu n'est pas
19
+ exploitable (site protégé par authwall/anti-bot JS-only, ex. LinkedIn) remonte désormais
20
+ le code d'erreur `url_empty_content` en mode `--json` au lieu de produire un fichier vide
21
+ avec un statut de succès.
22
+
23
+ ## 0.5.3
13
24
 
14
25
  - Aucun changement fonctionnel côté CLI/sidecars : bump lockstep pour rester
15
- aligné avec la version de l'app desktop (1.5.2).
26
+ aligné avec la version de l'app desktop (1.5.3).
16
27
 
17
28
  ## 0.5.1
18
29
 
package/dist/index.js CHANGED
@@ -102,42 +102,25 @@ function needsPngNormalization(ext) {
102
102
  async function toPngBuffer(input) {
103
103
  return (0, import_sharp.default)(input).png().toBuffer();
104
104
  }
105
- async function shrinkToMaxBytes(input, maxBytes) {
106
- const QUALITY = 80;
107
- let output = await (0, import_sharp.default)(input).jpeg({ quality: QUALITY }).toBuffer();
108
- if (output.length <= maxBytes) return output;
109
- const meta = await (0, import_sharp.default)(input).metadata();
110
- let width = meta.width ?? 1600;
111
- while (output.length > maxBytes && width > 200) {
112
- width = Math.round(width * 0.75);
113
- output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality: QUALITY }).toBuffer();
114
- }
115
- let quality = QUALITY;
116
- while (output.length > maxBytes && quality > 20) {
117
- quality -= 15;
118
- output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality }).toBuffer();
119
- }
120
- return output;
121
- }
122
105
 
123
106
  // ../core/src/ocr-models.ts
124
107
  var OCR_MODELS = {
125
108
  openai: "gpt-4o",
126
109
  mistral: "mistral-ocr-latest",
127
110
  claude: "claude-sonnet-4-6",
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",
111
+ // Bascule 02/09/2026 : nemotron-nano-12b-v2-vl (et toute la lignée nemotron
112
+ // vision-chat, nano-vl-8b-v1 y compris) retirée par NVIDIA — HTTP 410 "end
113
+ // of life" confirmé en direct (26/08/2026) sur les deux modèles. Remplacé
114
+ // par nemotron-ocr-v2 : modèle OCR dédié (pas un chat vision-langage), API
115
+ // /v1/cv séparée — voir nvidia-provider.ts. Détection de texte fiable
116
+ // (pas d'hallucination possible, contrairement aux chat vision), mais pas
117
+ // de description d'image ni de traduction : NVIDIA_FALLBACK_MODEL
118
+ // (llama-3.2-90b-vision-instruct) prend le relais pour ces deux cas.
119
+ nvidia: "nvidia/nemotron-ocr-v2",
138
120
  gemini: "gemini-2.0-flash"
139
121
  };
140
122
  var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
123
+ var NVIDIA_FALLBACK_MODEL = "meta/llama-3.2-90b-vision-instruct";
141
124
 
142
125
  // ../core/src/sidecar/manager.ts
143
126
  var import_node_child_process = require("node:child_process");
@@ -724,63 +707,126 @@ function convertLatexTables(markdown) {
724
707
  }
725
708
 
726
709
  // ../core/src/ocr/nvidia-provider.ts
710
+ var import_sharp2 = __toESM(require("sharp"));
727
711
  var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
728
- var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
729
- var NVIDIA_MAX_IMAGE_BYTES = 17e4;
712
+ var OCR_ENDPOINT = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v2";
713
+ var CHAT_ENDPOINT2 = "https://integrate.api.nvidia.com/v1/chat/completions";
714
+ function median(nums) {
715
+ if (nums.length === 0) return 0;
716
+ const sorted = [...nums].sort((a, b) => a - b);
717
+ const mid = Math.floor(sorted.length / 2);
718
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
719
+ }
720
+ function groupLines(blocks) {
721
+ const lines = [];
722
+ for (const block of [...blocks].sort((a, b) => a.top - b.top)) {
723
+ const current = lines[lines.length - 1];
724
+ if (current) {
725
+ const envTop = Math.min(...current.map((b) => b.top));
726
+ const envBottom = Math.max(...current.map((b) => b.bottom));
727
+ const maxHeight = Math.max(...current.map((b) => b.height), block.height);
728
+ const overlaps = block.top < envBottom && block.bottom > envTop;
729
+ const newHeight = Math.max(envBottom, block.bottom) - Math.min(envTop, block.top);
730
+ if (overlaps && newHeight <= maxHeight * 1.6) {
731
+ current.push(block);
732
+ continue;
733
+ }
734
+ }
735
+ lines.push([block]);
736
+ }
737
+ return lines;
738
+ }
739
+ var BULLET_RE = /^[•▪◦○*·-]\s+/;
740
+ function reconstructReadingOrder(detections) {
741
+ const blocks = detections.map((d) => {
742
+ const text = d.text_prediction?.text?.trim();
743
+ const points = d.bounding_box?.points;
744
+ if (!text || !points || points.length === 0) return null;
745
+ const ys = points.map((p) => p.y);
746
+ const xs = points.map((p) => p.x);
747
+ const top = Math.min(...ys);
748
+ const bottom = Math.max(...ys);
749
+ return { text, top, bottom, left: Math.min(...xs), height: bottom - top };
750
+ }).filter((b) => b !== null);
751
+ const lines = groupLines(blocks);
752
+ const typicalHeight = median(lines.map((line) => median(line.map((b) => b.height)))) || 0.02;
753
+ const out = [];
754
+ let previousBottom = null;
755
+ for (const line of lines) {
756
+ const top = Math.min(...line.map((b) => b.top));
757
+ const bottom = Math.max(...line.map((b) => b.bottom));
758
+ const height = median(line.map((b) => b.height));
759
+ if (previousBottom !== null && top - previousBottom > typicalHeight * 0.9) out.push("");
760
+ previousBottom = bottom;
761
+ let text = [...line].sort((a, b) => a.left - b.left).map((b) => b.text).join(" ");
762
+ const bullet = text.match(BULLET_RE);
763
+ if (bullet) text = `- ${text.slice(bullet[0].length)}`;
764
+ else if (line.length === 1 && height > typicalHeight * 1.25 && text.split(" ").length <= 8) {
765
+ text = `## ${text}`;
766
+ }
767
+ out.push(text);
768
+ }
769
+ return out.join("\n");
770
+ }
771
+ async function describeOrTranslateFallback(img, mime, apiKey, signal, prompt) {
772
+ const content = [{ type: "text", text: prompt }];
773
+ if (img && mime) {
774
+ content.push({ type: "image_url", image_url: { url: toDataUrl(img, mime) } });
775
+ }
776
+ const response = await fetch(CHAT_ENDPOINT2, {
777
+ method: "POST",
778
+ signal,
779
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
780
+ body: JSON.stringify({
781
+ model: NVIDIA_FALLBACK_MODEL,
782
+ max_tokens: 4096,
783
+ frequency_penalty: 0.4,
784
+ temperature: 0,
785
+ messages: [{ role: "user", content }]
786
+ })
787
+ });
788
+ if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
789
+ const payload = await response.json();
790
+ const markdown = payload.choices?.[0]?.message?.content;
791
+ if (typeof markdown !== "string" || markdown.length === 0) {
792
+ throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
793
+ }
794
+ return { markdown, raw: payload };
795
+ }
730
796
  var nvidiaProvider = {
731
797
  id: "nvidia",
732
798
  model: NVIDIA_OCR_MODEL,
733
799
  async convertImage(img, mime, apiKey, signal, prompt) {
800
+ if (!prompt.includes(NO_TEXT_SENTINEL)) {
801
+ return describeOrTranslateFallback(img, mime, apiKey, signal, prompt);
802
+ }
734
803
  let payloadImg = img;
735
804
  let payloadMime = mime;
736
- if (payloadMime === "image/webp" || payloadImg.length > NVIDIA_MAX_IMAGE_BYTES) {
737
- payloadImg = await shrinkToMaxBytes(payloadImg, NVIDIA_MAX_IMAGE_BYTES);
805
+ if (payloadMime !== "image/png" && payloadMime !== "image/jpeg") {
806
+ payloadImg = await (0, import_sharp2.default)(img).jpeg({ quality: 90 }).toBuffer();
738
807
  payloadMime = "image/jpeg";
739
808
  }
740
- const response = await fetch(ENDPOINT4, {
809
+ const response = await fetch(OCR_ENDPOINT, {
741
810
  method: "POST",
742
811
  signal,
743
- headers: {
744
- "Content-Type": "application/json",
745
- Authorization: `Bearer ${apiKey}`
746
- },
812
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
747
813
  body: JSON.stringify({
748
- model: NVIDIA_OCR_MODEL,
749
- max_tokens: 4096,
750
- // Llama vision peut dégénérer en boucle de répétition sans pénalité
751
- // (observé en réel : même phrase répétée ~200 fois jusqu'à max_tokens).
752
- frequency_penalty: 0.4,
753
- // Petit modèle (11B) : sans température 0 ni cadrage renforcé, il "interprète"
754
- // (résumé en anglais observé en réel 12/07/2026 au lieu d'une transcription).
755
- // ATTENTION : pas de message system — le endpoint NIM llama vision rejette
756
- // les requêtes system + image (constaté en réel 12/07/2026, appel en échec).
757
- temperature: 0,
758
- messages: [
759
- {
760
- role: "user",
761
- content: [
762
- {
763
- type: "text",
764
- text: "You are an OCR engine. You copy text from images character by character. You never summarize, never translate, never comment, never add steps or headings of your own. You only describe the image in the single case where it contains no readable text at all, exactly as instructed below.\n\n" + prompt
765
- },
766
- { type: "image_url", image_url: { url: toDataUrl(payloadImg, payloadMime) } }
767
- ]
768
- }
769
- ]
814
+ input: [{ type: "image_url", url: toDataUrl(payloadImg, payloadMime) }],
815
+ merge_levels: ["paragraph"]
770
816
  })
771
817
  });
772
818
  if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
773
819
  const payload = await response.json();
774
- const markdown = payload.choices?.[0]?.message?.content;
775
- if (typeof markdown !== "string" || markdown.length === 0) {
776
- throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
820
+ const detections = payload.data?.[0]?.text_detections ?? [];
821
+ if (detections.length === 0) {
822
+ return { markdown: NO_TEXT_SENTINEL, raw: payload };
777
823
  }
778
- return { markdown, raw: payload };
824
+ return { markdown: reconstructReadingOrder(detections), raw: payload };
779
825
  },
780
826
  translateText(text, apiKey, signal, prompt) {
781
827
  return callOpenAiCompatibleChatText(
782
- ENDPOINT4,
783
- NVIDIA_OCR_MODEL,
828
+ CHAT_ENDPOINT2,
829
+ NVIDIA_FALLBACK_MODEL,
784
830
  apiKey,
785
831
  signal,
786
832
  prompt,
@@ -788,19 +834,19 @@ var nvidiaProvider = {
788
834
  "nvidia",
789
835
  8192,
790
836
  0.4
791
- // même garde-fou anti-répétition que convertImage ci-dessus
837
+ // même garde-fou anti-répétition qu'en OCR (voir callOpenAiCompatibleChatText)
792
838
  );
793
839
  }
794
840
  };
795
841
 
796
842
  // ../core/src/ocr/openai-provider.ts
797
843
  var OPENAI_OCR_MODEL = OCR_MODELS.openai;
798
- var ENDPOINT5 = "https://api.openai.com/v1/chat/completions";
844
+ var ENDPOINT4 = "https://api.openai.com/v1/chat/completions";
799
845
  var openAiProvider = {
800
846
  id: "openai",
801
847
  model: OPENAI_OCR_MODEL,
802
848
  async convertImage(img, mime, apiKey, signal, prompt) {
803
- const response = await fetch(ENDPOINT5, {
849
+ const response = await fetch(ENDPOINT4, {
804
850
  method: "POST",
805
851
  signal,
806
852
  headers: {
@@ -831,7 +877,7 @@ var openAiProvider = {
831
877
  },
832
878
  translateText(text, apiKey, signal, prompt) {
833
879
  return callOpenAiCompatibleChatText(
834
- ENDPOINT5,
880
+ ENDPOINT4,
835
881
  OPENAI_OCR_MODEL,
836
882
  apiKey,
837
883
  signal,
@@ -1522,8 +1568,31 @@ function buildJsonEnvelope(doc) {
1522
1568
  function serializeJsonExport(doc) {
1523
1569
  return JSON.stringify(buildJsonEnvelope(doc), null, 2);
1524
1570
  }
1571
+ var ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\x00-\x1f]/g;
1572
+ function sanitizeFilename(name) {
1573
+ return name.replace(ILLEGAL_FILENAME_CHARS, " ").replace(/\s+/g, " ").trim();
1574
+ }
1575
+ var MAX_FILENAME_BYTES = 255;
1576
+ var RESERVED_SUFFIX_BYTES = 11;
1577
+ var encoder = new TextEncoder();
1578
+ function truncateToByteLength(name, maxBytes) {
1579
+ if (encoder.encode(name).length <= maxBytes) return name;
1580
+ let result = "";
1581
+ let bytes = 0;
1582
+ for (const char of name) {
1583
+ const charBytes = encoder.encode(char).length;
1584
+ if (bytes + charBytes > maxBytes) break;
1585
+ result += char;
1586
+ bytes += charBytes;
1587
+ }
1588
+ return result.trim();
1589
+ }
1525
1590
  async function resolveCollisionPath(dir, stem, ext) {
1526
- const candidate = (n2) => (0, import_node_path3.join)(dir, n2 === 0 ? `${stem}${ext}` : `${stem} (${n2})${ext}`);
1591
+ const safeStem = truncateToByteLength(
1592
+ sanitizeFilename(stem),
1593
+ MAX_FILENAME_BYTES - RESERVED_SUFFIX_BYTES
1594
+ );
1595
+ const candidate = (n2) => (0, import_node_path3.join)(dir, n2 === 0 ? `${safeStem}${ext}` : `${safeStem} (${n2})${ext}`);
1527
1596
  let n = 0;
1528
1597
  while (n < 999) {
1529
1598
  try {
@@ -1709,6 +1778,7 @@ var CONVERSION_CODE_MAP = {
1709
1778
  TRANSLATION_INCOHERENT: "translation_incoherent",
1710
1779
  URL_FORBIDDEN: "url_forbidden",
1711
1780
  URL_UNREACHABLE: "url_unreachable",
1781
+ URL_EMPTY_CONTENT: "url_empty_content",
1712
1782
  HEIC_HEVC_UNSUPPORTED: "heic_hevc_unsupported",
1713
1783
  IMAGE_DECODE_UNSUPPORTED: "image_decode_unsupported",
1714
1784
  UNKNOWN: "unknown"
@@ -1741,6 +1811,8 @@ function toCliError(err) {
1741
1811
  return { code: "url_forbidden", message: err.message };
1742
1812
  case "URL_UNREACHABLE":
1743
1813
  return { code: "url_unreachable", message: err.message };
1814
+ case "URL_EMPTY_CONTENT":
1815
+ return { code: "url_empty_content", message: err.message };
1744
1816
  default:
1745
1817
  return { code: "conversion_failed", message: err.message };
1746
1818
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygal-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
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.5.3",
41
- "@sygal/sidecar-win32-x64": "0.5.3",
42
- "@sygal/sidecar-linux-x64": "0.5.3"
40
+ "@sygal/sidecar-darwin-arm64": "0.5.6",
41
+ "@sygal/sidecar-win32-x64": "0.5.6",
42
+ "@sygal/sidecar-linux-x64": "0.5.6"
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",