Aggiunge tre capacita' al lettore, tutte senza dipendenze esterne.
Anteprima incorporata (preview.py)
Ogni file contiene una miniatura JPEG dell'ultimo salvataggio: il
render del pezzo nei modelli, l'immagine della tavola nei disegni.
Non e' referenziata dall'indice, quindi va cercata per firma: ogni
candidato viene validato percorrendone i segmenti JPEG, cosi' i byte
che iniziano per FFD8FF per caso non passano. Sui file campione il
risultato e' esattamente una immagine per file.
Decompressione compress(1) (lzw.py)
Creo comprime alcuni blocchi nel vecchio formato .Z. Rispetto a un
LZW generico va riprodotto l'allineamento a gruppi di otto codici:
senza, la decompressione produce qualche centinaio di byte corretti
e poi diverge silenziosamente.
Contenuto dei disegni (drawing.py)
Un .drw non ha la tabella parametri del modello ma contiene la
rappresentazione vettoriale della tavola. Ne estraiamo i testi:
cartiglio, note, tolleranze, quote. Le tavole diventano cosi'
ricercabili per contenuto. Nel disegno i valori compaiono gia'
risolti come testo, compreso il peso, mentre nel .prt sono ancora
in codifica binaria.
I blocchi oltre l'indice sono ora interpretati da ugc.iter_blocks, che
decomprime quando serve: lo stesso blocco puo' essere memorizzato
compresso in un file e in chiaro in un altro, quindi la scelta va fatta
guardando i byte e non il nome. Cercare soltanto gli stream compressi
faceva perdere i testi su un terzo dei disegni.
Sulla codifica dei numeri: sono double IEEE big-endian con i byte bassi
omessi, preceduti da un byte-tag. Lo dimostrano i disegni, dove size_x e
size_y valgono 420,0 x 297,0 (un A3 esatto). La mappatura del byte-tag
resta pero' incerta, quindi PESO e DENSITA continuano a essere riportati
come encoded_not_decoded: un valore plausibile ma sbagliato sarebbe
peggio di un valore assente.
La geometria 3D resta fuori portata, e ora e' verificato: in 200 KB di
blocchi geometrici non compare una sola costante 1.0 in nessuna codifica
in virgola mobile. Quei blocchi sono compressi con un algoritmo che non
espone firme note.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""Interfaccia a riga di comando."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from . import ugc
|
|
from .extract import extract as extract_file
|
|
from .extract import latest_versions, read_preview, split_filename
|
|
|
|
CREO_EXTENSIONS = (".prt", ".asm", ".drw", ".frm", ".lay")
|
|
|
|
#: Parametri aziendali attesi: se mancano vengono riportati esplicitamente
|
|
#: come "parameter_not_present" invece di sparire dall'output.
|
|
DEFAULT_EXPECTED = [
|
|
"CODICE",
|
|
"DENOMINAZIONE",
|
|
"REVISIONE",
|
|
"STAMPIGLIATURA",
|
|
"MATERIALE",
|
|
"DISEGNATORE",
|
|
"DATA",
|
|
]
|
|
|
|
|
|
def _is_creo_file(path: str) -> bool:
|
|
stem, ext, _ = split_filename(os.path.basename(path))
|
|
return f".{ext}" in CREO_EXTENSIONS
|
|
|
|
|
|
def collect(inputs: "list[str]", recursive: bool) -> "tuple[list[str], list[str]]":
|
|
"""Separa i file indicati esplicitamente da quelli trovati esplorando una
|
|
cartella: solo su questi ultimi ha senso scegliere l'ultima versione."""
|
|
explicit, discovered = [], []
|
|
for item in inputs:
|
|
if os.path.isdir(item):
|
|
walker = os.walk(item) if recursive else [(item, [], os.listdir(item))]
|
|
for root, _, files in walker:
|
|
discovered.extend(
|
|
os.path.join(root, f) for f in files if _is_creo_file(f)
|
|
)
|
|
else:
|
|
explicit.append(item)
|
|
return sorted(set(explicit)), sorted(set(discovered))
|
|
|
|
|
|
def _visible(record: dict, include_system: bool, include_features: bool = False) -> dict:
|
|
return {
|
|
name: entry
|
|
for name, entry in record["parameters"].items()
|
|
if (include_system or entry.get("scope") != "system")
|
|
and (include_features or entry.get("owner", "model") == "model")
|
|
}
|
|
|
|
|
|
def _print_table(records: "list[dict]", stream, include_system: bool = False,
|
|
include_features: bool = False, limit_texts: int = 20) -> None:
|
|
for record in records:
|
|
model = record["model"]
|
|
header = f"{record['file']['name']} [{model['kind']}"
|
|
if model["subtype"]:
|
|
header += f"/{model['subtype']}"
|
|
header += f"] Creo {model['creo_version'] or '?'}"
|
|
shot = record.get("preview", {})
|
|
if shot.get("present"):
|
|
header += f" anteprima {shot['width']}x{shot['height']}"
|
|
print(header, file=stream)
|
|
print("-" * len(header), file=stream)
|
|
|
|
if not record["extraction"]["holds_model_parameters"]:
|
|
sheet = record.get("drawing")
|
|
if sheet and sheet["texts"]:
|
|
print(" %d testi nella tavola (%d unici). I parametri del "
|
|
"modello stanno nel .prt." % (
|
|
sheet["text_count"], len(sheet["texts"])), file=stream)
|
|
for text in sheet["texts"][:limit_texts]:
|
|
print(" %s" % text, file=stream)
|
|
if len(sheet["texts"]) > limit_texts:
|
|
print(" ... altri %d (usare -o per averli tutti)" % (
|
|
len(sheet["texts"]) - limit_texts), file=stream)
|
|
else:
|
|
print(" (i disegni non contengono la tabella parametri del modello)",
|
|
file=stream)
|
|
print(file=stream)
|
|
continue
|
|
|
|
for name, entry in _visible(record, include_system, include_features).items():
|
|
status = entry.get("status")
|
|
if status == "found":
|
|
shown = entry["value"] if entry["value"] != "" else "(vuoto)"
|
|
mark = " " if entry.get("confidence", 0) >= 0.95 else "?"
|
|
elif status == "encoded_not_decoded":
|
|
shown = "<binario non decodificato>"
|
|
mark = "~"
|
|
elif status == "conflict":
|
|
shown = " | ".join(entry.get("candidates", []))
|
|
mark = "!"
|
|
else:
|
|
shown = "(assente)"
|
|
mark = "-"
|
|
print(f" {mark} {name:<22} {shown}", file=stream)
|
|
print(file=stream)
|
|
|
|
|
|
def _preview_path(source: str, dest: str, single: bool) -> str:
|
|
"""Decide dove scrivere l'anteprima di `source`.
|
|
|
|
Senza destinazione la mette accanto all'originale; se la destinazione e'
|
|
una cartella ci scrive dentro; un nome esplicito vale solo con un file
|
|
solo, altrimenti i file si sovrascriverebbero a vicenda.
|
|
"""
|
|
name = os.path.basename(source) + ".jpg"
|
|
if not dest:
|
|
return source + ".jpg"
|
|
# La barra finale indica una cartella anche se non esiste ancora; con piu'
|
|
# file la destinazione e' comunque una cartella, altrimenti si
|
|
# sovrascriverebbero a vicenda.
|
|
looks_like_dir = dest.endswith(("/", os.sep)) or os.path.isdir(dest)
|
|
if looks_like_dir or not single:
|
|
os.makedirs(dest, exist_ok=True)
|
|
return os.path.join(dest, name)
|
|
return dest
|
|
|
|
|
|
def _save_previews(paths: "list[str]", dest: str, stream) -> None:
|
|
single = len(paths) == 1
|
|
for source in paths:
|
|
shot = read_preview(source)
|
|
if shot is None:
|
|
print(f"{os.path.basename(source)}: nessuna anteprima nel file",
|
|
file=stream)
|
|
continue
|
|
target = _preview_path(source, dest, single)
|
|
shot.save(target)
|
|
print(f"Anteprima {shot.width}x{shot.height} salvata in {target}",
|
|
file=stream)
|
|
|
|
|
|
def _write_csv(records: "list[dict]", path: str, include_system: bool = False,
|
|
include_features: bool = False) -> None:
|
|
names = sorted({n for r in records for n in _visible(r, include_system, include_features)})
|
|
columns = ["file", "model", "kind", "creo_version"] + names
|
|
|
|
with open(path, "w", newline="", encoding="utf-8-sig") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=columns, delimiter=";")
|
|
writer.writeheader()
|
|
for record in records:
|
|
row = {
|
|
"file": record["file"]["name"],
|
|
"model": record["model"]["name"],
|
|
"kind": record["model"]["kind"],
|
|
"creo_version": record["model"]["creo_version"],
|
|
}
|
|
for name in names:
|
|
entry = record["parameters"].get(name, {})
|
|
row[name] = entry.get("value") if entry.get("status") == "found" else ""
|
|
writer.writerow(row)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="creoparams",
|
|
description="Estrae parametri e proprieta' dai file nativi Creo "
|
|
"(.prt/.asm/.drw) senza avviare Creo Parametric.",
|
|
epilog="Esempi:\n"
|
|
" creoparams 9258400201.prt.12 un singolo file\n"
|
|
" creoparams 9258400201.prt.12 -o - JSON su stdout\n"
|
|
" creoparams /rete/archivio -r --csv i.csv intera cartella",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument("inputs", nargs="+", metavar="FILE|CARTELLA")
|
|
parser.add_argument("-o", "--json", metavar="FILE",
|
|
help="scrive il risultato in JSON; usare '-' per "
|
|
"stdout. Con un solo file emette un oggetto, "
|
|
"con piu' file una lista")
|
|
parser.add_argument("--csv", metavar="FILE",
|
|
help="scrive una tabella riepilogativa in CSV")
|
|
parser.add_argument("--preview", nargs="?", const="", metavar="FILE|CARTELLA",
|
|
help="salva l'anteprima incorporata come JPEG. Senza "
|
|
"argomento la scrive accanto al file di origine")
|
|
parser.add_argument("-r", "--recursive", action="store_true",
|
|
help="esplora le sottocartelle")
|
|
parser.add_argument("--all-versions", action="store_true",
|
|
help="elabora tutte le versioni (.prt.1, .prt.2, ...) "
|
|
"invece della sola piu' recente")
|
|
parser.add_argument("--system", action="store_true",
|
|
help="include anche i parametri generati da Creo "
|
|
"(PTC_*, SMT_*)")
|
|
parser.add_argument("--features", action="store_true",
|
|
help="include anche i parametri appartenenti alle "
|
|
"feature invece dei soli parametri di modello")
|
|
parser.add_argument("--quiet", action="store_true",
|
|
help="non stampa la tabella a video")
|
|
return parser
|
|
|
|
|
|
def main(argv: "list[str] | None" = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
|
|
explicit, discovered = collect(args.inputs, args.recursive)
|
|
if not args.all_versions:
|
|
# Un file indicato a mano si elabora com'e': la scelta della versione
|
|
# piu' recente riguarda solo l'esplorazione di una cartella.
|
|
discovered = latest_versions(discovered)
|
|
paths = sorted(set(explicit) | set(discovered))
|
|
|
|
if not paths:
|
|
print("Nessun file Creo trovato.", file=sys.stderr)
|
|
return 1
|
|
|
|
records, failures = [], []
|
|
for path in paths:
|
|
try:
|
|
records.append(extract_file(path, DEFAULT_EXPECTED))
|
|
except ugc.NotACreoFile as error:
|
|
failures.append((path, str(error)))
|
|
except OSError as error:
|
|
failures.append((path, str(error)))
|
|
|
|
if not args.quiet:
|
|
_print_table(records, sys.stdout, args.system, args.features)
|
|
|
|
if args.json:
|
|
# Un solo file produce un oggetto, non una lista di un elemento:
|
|
# e' la forma piu' comoda da consumare a valle.
|
|
payload = records[0] if len(records) == 1 else records
|
|
if args.json == "-":
|
|
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
with open(args.json, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
print(f"JSON scritto in {args.json}", file=sys.stderr)
|
|
|
|
if args.csv:
|
|
_write_csv(records, args.csv, args.system, args.features)
|
|
print(f"CSV scritto in {args.csv}", file=sys.stderr)
|
|
|
|
if args.preview is not None:
|
|
_save_previews(
|
|
[r["source_file"] for r in records], args.preview, sys.stderr
|
|
)
|
|
|
|
for path, reason in failures:
|
|
print(f"SALTATO {os.path.basename(path)}: {reason}", file=sys.stderr)
|
|
|
|
# Su un singolo file andato a buon fine il riepilogo e' solo rumore.
|
|
if len(paths) > 1 or failures:
|
|
print(
|
|
f"{len(records)} file elaborati, {len(failures)} saltati.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0 if records else 2
|