traslado 2.0.0__py3-none-any.whl
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.
- freoa_v6/__init__.py +53 -0
- traslado/__init__.py +4 -0
- traslado/__main__.py +6 -0
- traslado/advanced.py +179 -0
- traslado/assets/bootstrap_ocr.ps1 +303 -0
- traslado/assets/fixture.docx +0 -0
- traslado/assets/fixture.pdf +33 -0
- traslado/assets/ocr-assets.lock.json +437 -0
- traslado/assets/traslado.png +0 -0
- traslado/chunks.py +466 -0
- traslado/cli.py +236 -0
- traslado/detect.py +118 -0
- traslado/doctor.py +63 -0
- traslado/errors.py +88 -0
- traslado/formats.py +595 -0
- traslado/gui.py +781 -0
- traslado/legacy.py +248 -0
- traslado/lifecycle.py +1279 -0
- traslado/model.py +137 -0
- traslado/ocr.py +179 -0
- traslado/pathguard.py +18 -0
- traslado/pdf.py +520 -0
- traslado/platform.py +382 -0
- traslado/registry.py +122 -0
- traslado/render.py +189 -0
- traslado/review.py +55 -0
- traslado/revocation.py +444 -0
- traslado/runner.py +413 -0
- traslado/runtime.py +112 -0
- traslado/safety.py +275 -0
- traslado/windows_job.py +87 -0
- traslado/worker.py +126 -0
- traslado-2.0.0.dist-info/METADATA +328 -0
- traslado-2.0.0.dist-info/RECORD +38 -0
- traslado-2.0.0.dist-info/WHEEL +5 -0
- traslado-2.0.0.dist-info/entry_points.txt +7 -0
- traslado-2.0.0.dist-info/licenses/LICENCE +661 -0
- traslado-2.0.0.dist-info/top_level.txt +2 -0
freoa_v6/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ricardo Peres Freoa
|
|
3
|
+
"""Compatibilidade de módulos com Traslado durante a série 2.x."""
|
|
4
|
+
import importlib
|
|
5
|
+
import importlib.abc
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _AliasLoader(importlib.abc.Loader):
|
|
11
|
+
def __init__(self, target):
|
|
12
|
+
self.target = target
|
|
13
|
+
|
|
14
|
+
def create_module(self, spec):
|
|
15
|
+
module = importlib.import_module(self.target)
|
|
16
|
+
self.spec = module.__spec__
|
|
17
|
+
return module
|
|
18
|
+
|
|
19
|
+
def exec_module(self, module):
|
|
20
|
+
module.__spec__ = self.spec
|
|
21
|
+
|
|
22
|
+
def get_filename(self, fullname):
|
|
23
|
+
# runpy sets argv[0] from spec.origin; argparse on Python 3.11-3.13
|
|
24
|
+
# requires a filename there, even when the code comes from an alias.
|
|
25
|
+
return importlib.util.find_spec(self.target).origin
|
|
26
|
+
|
|
27
|
+
def get_code(self, fullname):
|
|
28
|
+
# runpy requests code without create_module/exec_module. Execute the
|
|
29
|
+
# canonical module as __main__, keeping its relative imports canonical.
|
|
30
|
+
return compile(
|
|
31
|
+
"import sys, runpy\n"
|
|
32
|
+
"if hasattr(sys.stderr, 'reconfigure'):\n"
|
|
33
|
+
" sys.stderr.reconfigure(encoding='utf-8', errors='backslashreplace')\n"
|
|
34
|
+
"print('Depreciação: use python -m traslado em vez de freoa_v6.', file=sys.stderr)\n"
|
|
35
|
+
f"runpy.run_module({self.target!r}, run_name='__main__', alter_sys=True)\n",
|
|
36
|
+
self.get_filename(fullname), "exec", dont_inherit=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _AliasFinder(importlib.abc.MetaPathFinder):
|
|
40
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
41
|
+
if not fullname.startswith('freoa_v6.'):
|
|
42
|
+
return None
|
|
43
|
+
canonical = 'traslado' + fullname[len('freoa_v6'):]
|
|
44
|
+
spec = importlib.util.find_spec(canonical)
|
|
45
|
+
if spec is None:
|
|
46
|
+
return None
|
|
47
|
+
return importlib.util.spec_from_loader(
|
|
48
|
+
fullname, _AliasLoader(canonical),
|
|
49
|
+
is_package=spec.submodule_search_locations is not None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
sys.meta_path.insert(0, _AliasFinder())
|
|
53
|
+
sys.modules[__name__] = importlib.import_module('traslado')
|
traslado/__init__.py
ADDED
traslado/__main__.py
ADDED
traslado/advanced.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ricardo Peres Freoa
|
|
3
|
+
"""Experimental opt-in bridge to separately installed MinerU Kit 4.0.10.
|
|
4
|
+
|
|
5
|
+
No engine/model is bundled. The bridge is a process boundary, not an OS sandbox.
|
|
6
|
+
HF offline variables prevent normal hub downloads, not arbitrary third-party IO.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
from html.parser import HTMLParser
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
from .formats import image_to_png
|
|
18
|
+
from .model import Block, Document, Options
|
|
19
|
+
from .safety import is_link, run_bounded
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PassiveHTML(HTMLParser):
|
|
23
|
+
def __init__(self):
|
|
24
|
+
super().__init__(convert_charrefs=False)
|
|
25
|
+
self.out = []
|
|
26
|
+
|
|
27
|
+
def handle_starttag(self, tag, attrs):
|
|
28
|
+
if tag in {"table", "thead", "tbody", "tr", "td", "th", "br"}:
|
|
29
|
+
safe = []
|
|
30
|
+
for key, value in attrs:
|
|
31
|
+
if key in {"colspan", "rowspan"} and value and value.isdigit() and 1 <= int(value) <= 1000:
|
|
32
|
+
safe.append(f' {key}="{int(value)}"')
|
|
33
|
+
self.out.append("<" + tag + "".join(safe) + ">")
|
|
34
|
+
else:
|
|
35
|
+
self.out.append(html.escape(self.get_starttag_text()))
|
|
36
|
+
|
|
37
|
+
def handle_endtag(self, tag):
|
|
38
|
+
self.out.append("</" + tag + ">" if tag in {"table", "thead", "tbody", "tr", "td", "th"} else html.escape("</" + tag + ">"))
|
|
39
|
+
|
|
40
|
+
def handle_startendtag(self, tag, attrs):
|
|
41
|
+
# HTMLParser's default invents an end tag, including a visible escaped
|
|
42
|
+
# </br> for <br/>. Keep void breaks canonical and other safe tags closed
|
|
43
|
+
# with their original self-closing form; unknown tags remain literal.
|
|
44
|
+
if tag in {"table", "thead", "tbody", "tr", "td", "th", "br"}:
|
|
45
|
+
self.handle_starttag(tag, attrs)
|
|
46
|
+
if tag != "br":
|
|
47
|
+
self.out[-1] = self.out[-1][:-1] + "/>"
|
|
48
|
+
else:
|
|
49
|
+
self.out.append(html.escape(self.get_starttag_text()))
|
|
50
|
+
|
|
51
|
+
def handle_data(self, data):
|
|
52
|
+
self.out.append(html.escape(data, quote=False))
|
|
53
|
+
|
|
54
|
+
def handle_comment(self, data):
|
|
55
|
+
self.out.append(html.escape("<!--" + data + "-->"))
|
|
56
|
+
|
|
57
|
+
def handle_decl(self, decl):
|
|
58
|
+
self.out.append(html.escape("<!" + decl + ">"))
|
|
59
|
+
|
|
60
|
+
def handle_pi(self, data):
|
|
61
|
+
self.out.append(html.escape("<?" + data + ">"))
|
|
62
|
+
|
|
63
|
+
def unknown_decl(self, data):
|
|
64
|
+
self.out.append(html.escape("<![" + data + "]>"))
|
|
65
|
+
|
|
66
|
+
def handle_entityref(self, name):
|
|
67
|
+
self.out.append("&" + name + ";")
|
|
68
|
+
|
|
69
|
+
def handle_charref(self, name):
|
|
70
|
+
self.out.append("&#" + name + ";")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sanitize(markdown: str, model_root: Path, workspace: Path, page_number: int, options: Options) -> str:
|
|
74
|
+
"""Literalize model link syntax; retain only our reconstructed local images.
|
|
75
|
+
|
|
76
|
+
This deliberately sacrifices Markdown link/reference formatting instead of
|
|
77
|
+
trying to recognize CommonMark with regular expressions. Nothing is deleted
|
|
78
|
+
merely because it resembles a reference definition. The narrow image regex
|
|
79
|
+
is an optional importer, never a security filter: unmatched syntax remains
|
|
80
|
+
literal. Markers cannot collide with raw or entity-decoded model text.
|
|
81
|
+
"""
|
|
82
|
+
image_count = 0
|
|
83
|
+
marker_prefix = "FREOAASSETMARKER0X"
|
|
84
|
+
decoded = html.unescape(markdown)
|
|
85
|
+
marker_number = 0
|
|
86
|
+
while marker_prefix in markdown or marker_prefix in decoded:
|
|
87
|
+
marker_number += 1
|
|
88
|
+
marker_prefix = f"FREOAASSETMARKER{marker_number}X"
|
|
89
|
+
markers = {}
|
|
90
|
+
root = model_root.resolve()
|
|
91
|
+
|
|
92
|
+
def local_image(match):
|
|
93
|
+
nonlocal image_count
|
|
94
|
+
relative = match.group(1)
|
|
95
|
+
parts = Path(relative).parts
|
|
96
|
+
if (not options.images or not re.fullmatch(r"[a-zA-Z0-9_.\-/]+", relative)
|
|
97
|
+
or relative.startswith("/") or any(p in {".", ".."} for p in parts)):
|
|
98
|
+
return match.group(0)
|
|
99
|
+
file = root / relative
|
|
100
|
+
# Check before resolving, including every intermediate directory.
|
|
101
|
+
if any(is_link(p) for p in [model_root, file, *file.parents] if p == root or root in p.parents or p == model_root):
|
|
102
|
+
return match.group(0)
|
|
103
|
+
file = file.resolve()
|
|
104
|
+
if root not in file.parents or not file.is_file() or file.stat().st_size > options.max_output_bytes:
|
|
105
|
+
return match.group(0)
|
|
106
|
+
image_count += 1
|
|
107
|
+
target = workspace / "assets" / f"advanced-{page_number}-{image_count}.png"
|
|
108
|
+
try:
|
|
109
|
+
image_to_png(file, target, options)
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
target.unlink(missing_ok=True)
|
|
112
|
+
raise
|
|
113
|
+
marker = f"{marker_prefix}{image_count}END"
|
|
114
|
+
markers[marker] = f""
|
|
115
|
+
return marker
|
|
116
|
+
|
|
117
|
+
markdown = re.sub(r'!\[[^\]\n]*\]\(([^)\n]*)\)', local_image, markdown)
|
|
118
|
+
parser = PassiveHTML()
|
|
119
|
+
parser.feed(markdown)
|
|
120
|
+
parser.close()
|
|
121
|
+
passive = "".join(parser.out).replace("[", "[").replace("]", "]")
|
|
122
|
+
for marker, image in markers.items():
|
|
123
|
+
passive = passive.replace(marker, image)
|
|
124
|
+
return passive
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def refine(source: Path, workspace: Path, options: Options, document: Document) -> None:
|
|
128
|
+
if not options.advanced_kit:
|
|
129
|
+
return
|
|
130
|
+
executable = Path(options.advanced_kit)
|
|
131
|
+
if not executable.is_absolute() or not executable.is_file():
|
|
132
|
+
raise ValueError("Informe caminho absoluto de mineru-kit instalado separadamente")
|
|
133
|
+
import fitz
|
|
134
|
+
targets = [p for p in document.pages if p.route == "ocr" or any(
|
|
135
|
+
w.startswith(("region_ocr_failed", "pdf_table_merged", "reading_order_two_column", "table_detection_failed")) for w in p.warnings)]
|
|
136
|
+
if not targets:
|
|
137
|
+
return
|
|
138
|
+
env = os.environ.copy()
|
|
139
|
+
env.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", HF_DATASETS_OFFLINE="1")
|
|
140
|
+
with tempfile.TemporaryDirectory(prefix="advanced-", dir=workspace) as name:
|
|
141
|
+
temporary = Path(name)
|
|
142
|
+
version_log = temporary / "version.log"
|
|
143
|
+
code = run_bounded([str(executable), "--version"], temporary, 10, version_log, env=env)
|
|
144
|
+
version = version_log.read_text(encoding="utf-8", errors="replace")
|
|
145
|
+
if code != 0 or not re.search(r"(?<![\d.])4\.0\.10(?![\d.])", version):
|
|
146
|
+
document.warnings.append("advanced_unsupported_version_expected_4.0.10")
|
|
147
|
+
return
|
|
148
|
+
with fitz.open(source) as pdf:
|
|
149
|
+
for page in targets:
|
|
150
|
+
folder = temporary / f"page-{page.number}"
|
|
151
|
+
folder.mkdir()
|
|
152
|
+
selected = folder / "selected.pdf"
|
|
153
|
+
with fitz.open() as one:
|
|
154
|
+
one.insert_pdf(pdf, from_page=page.number-1, to_page=page.number-1)
|
|
155
|
+
one.save(selected)
|
|
156
|
+
output = folder / "result.md"
|
|
157
|
+
try:
|
|
158
|
+
code = run_bounded([str(executable), "parse", str(selected), "-o", str(output),
|
|
159
|
+
"--tier", "advanced"], folder, options.advanced_timeout, folder / "parse.log", env=env)
|
|
160
|
+
if code != 0 or not output.is_file() or output.stat().st_size > options.max_output_bytes:
|
|
161
|
+
raise RuntimeError("MinerU não produziu resultado válido; mantenha modelos locais preparados")
|
|
162
|
+
content = output.read_text(encoding="utf-8")
|
|
163
|
+
if not content.strip():
|
|
164
|
+
raise RuntimeError("MinerU produziu resultado vazio")
|
|
165
|
+
passive = sanitize(content, folder, workspace, page.number, options)
|
|
166
|
+
# Keep previous extraction as an explicit alternative, never erase provenance.
|
|
167
|
+
# T11: line geometry is internal (never serialized); role only when classified.
|
|
168
|
+
previous = [{k: v for k, v in b.__dict__.items()
|
|
169
|
+
if k != "lines" and not (k == "role" and v is None)} for b in page.blocks]
|
|
170
|
+
page.blocks = [Block("text", passive, engine="mineru-kit-4.0.10",
|
|
171
|
+
details={"tier": "advanced", "previous_blocks": previous,
|
|
172
|
+
"source_page": page.number, "model_revision": "unverified",
|
|
173
|
+
"raw_model_markdown": content,
|
|
174
|
+
"markdown_policy": "literal_links_and_references_local_images_only"})]
|
|
175
|
+
page.route = "advanced"
|
|
176
|
+
page.warnings.append("model_reconstruction_requires_source_review")
|
|
177
|
+
page.warnings.append("model_markdown_links_literalized_formatting_partial")
|
|
178
|
+
except (RuntimeError, TimeoutError, ValueError, OSError) as exc:
|
|
179
|
+
page.warnings.append("advanced_failed: " + str(exc))
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ricardo Peres Freoa
|
|
3
|
+
[CmdletBinding()]
|
|
4
|
+
param(
|
|
5
|
+
[string]$Destination,
|
|
6
|
+
[string[]]$Languages = @('por', 'eng', 'spa', 'ita', 'fra', 'deu'),
|
|
7
|
+
[string]$CacheDirectory,
|
|
8
|
+
[string]$LockPath,
|
|
9
|
+
[switch]$Offline
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Extract authenticated official archives; never execute their installers.
|
|
13
|
+
# The caller supplies a new directory inside its unpublished release stage.
|
|
14
|
+
Set-StrictMode -Version Latest
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
if (-not $LockPath) { $LockPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'ocr-assets.lock.json' }
|
|
17
|
+
|
|
18
|
+
function Get-OcrReparseTag([string]$Path) {
|
|
19
|
+
if (-not ('FreoaOcrReparseNative' -as [type])) {
|
|
20
|
+
Add-Type -TypeDefinition @'
|
|
21
|
+
using System;
|
|
22
|
+
using System.ComponentModel;
|
|
23
|
+
using System.Runtime.InteropServices;
|
|
24
|
+
using Microsoft.Win32.SafeHandles;
|
|
25
|
+
public static class FreoaOcrReparseNative {
|
|
26
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
27
|
+
private struct AttributeTagInfo { public uint Attributes; public uint Tag; }
|
|
28
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
|
29
|
+
private static extern SafeFileHandle CreateFileW(string name, uint access, uint share,
|
|
30
|
+
IntPtr security, uint disposition, uint flags, IntPtr template);
|
|
31
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
32
|
+
private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,
|
|
33
|
+
int informationClass, out AttributeTagInfo info, uint size);
|
|
34
|
+
public static uint GetTag(string path) {
|
|
35
|
+
// Query the placeholder itself; never follow a name-surrogate link.
|
|
36
|
+
using (SafeFileHandle handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3,
|
|
37
|
+
0x02200000, IntPtr.Zero)) {
|
|
38
|
+
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
39
|
+
AttributeTagInfo info;
|
|
40
|
+
if (!GetFileInformationByHandleEx(handle, 9, out info, 8))
|
|
41
|
+
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
42
|
+
return info.Tag;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
'@
|
|
47
|
+
}
|
|
48
|
+
return [FreoaOcrReparseNative]::GetTag($Path)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Test-OcrCloudTag([uint32]$Tag) {
|
|
52
|
+
# Microsoft CLOUD and CLOUD_1..F differ only in bits 12..15.
|
|
53
|
+
# All other tags, including symlink/junction/name-surrogate tags, fail closed.
|
|
54
|
+
return (($Tag -band [uint32]4294905855) -eq [uint32]2415919130)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Assert-OcrNoLinks([string]$Path) {
|
|
58
|
+
$cursor = [IO.Path]::GetFullPath($Path)
|
|
59
|
+
while ($cursor) {
|
|
60
|
+
if (Test-Path -LiteralPath $cursor) {
|
|
61
|
+
$item = Get-Item -LiteralPath $cursor -Force
|
|
62
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
|
|
63
|
+
-not (Test-OcrCloudTag (Get-OcrReparseTag $cursor))) {
|
|
64
|
+
throw "OCR: links/junctions nao permitidos: $cursor"
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
# Get-Item also finds dangling links which Test-Path may omit.
|
|
68
|
+
$item = Get-Item -LiteralPath $cursor -Force -ErrorAction SilentlyContinue
|
|
69
|
+
if ($item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
|
|
70
|
+
-not (Test-OcrCloudTag (Get-OcrReparseTag $cursor))) {
|
|
71
|
+
throw "OCR: link pendente nao permitido: $cursor"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
$parent = [IO.Path]::GetDirectoryName($cursor)
|
|
75
|
+
if ($parent -eq $cursor) { break }
|
|
76
|
+
$cursor = $parent
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Assert-OcrMemberName([string]$Name) {
|
|
81
|
+
if ($Name -notmatch '^[A-Za-z0-9_.+-]+(?:/[A-Za-z0-9_.+-]+)*$' -or
|
|
82
|
+
@($Name.Split('/') | Where-Object { $_ -eq '.' -or $_ -eq '..' -or $_.StartsWith('-') }).Count) {
|
|
83
|
+
throw "OCR: membro invalido no lock: $Name"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Get-OcrLockedFile([string]$Path, $Specification) {
|
|
88
|
+
Assert-OcrNoLinks $Path
|
|
89
|
+
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
|
90
|
+
try {
|
|
91
|
+
if ($stream.Length -ne [long]$Specification.bytes) { throw "OCR: tamanho divergente: $Path" }
|
|
92
|
+
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
93
|
+
try { $actual = [BitConverter]::ToString($algorithm.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
|
|
94
|
+
finally { $algorithm.Dispose() }
|
|
95
|
+
if ($actual -ne $Specification.sha256) { throw "OCR: SHA256 divergente: $Path" }
|
|
96
|
+
$stream.Position = 0
|
|
97
|
+
return [pscustomobject]@{ Path = $Path; Handle = $stream }
|
|
98
|
+
} catch { $stream.Dispose(); throw }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Save-OcrPinnedAsset($Asset, [string]$Cache, [bool]$OnlyOffline) {
|
|
102
|
+
Assert-OcrMemberName $Asset.name
|
|
103
|
+
if ($Asset.name.Contains('/')) { throw 'OCR: nome de asset deve ser simples' }
|
|
104
|
+
if ($Asset.sha256 -notmatch '^[a-f0-9]{64}$' -or [long]$Asset.bytes -lt 1 -or [long]$Asset.bytes -gt 100000000) {
|
|
105
|
+
throw 'OCR: hash/tamanho invalido no lock'
|
|
106
|
+
}
|
|
107
|
+
$target = Join-Path $Cache $Asset.name
|
|
108
|
+
if (Test-Path -LiteralPath $target) { return Get-OcrLockedFile $target $Asset }
|
|
109
|
+
if ($OnlyOffline) { throw "OCR: cache offline incompleto: $($Asset.name)" }
|
|
110
|
+
$allowed = @('github.com', 'raw.githubusercontent.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
|
|
111
|
+
$uri = [Uri]$Asset.url
|
|
112
|
+
if ($uri.Scheme -ne 'https' -or $uri.Host -notin $allowed -or $uri.UserInfo) { throw 'OCR: origem HTTPS invalida' }
|
|
113
|
+
Add-Type -AssemblyName System.Net.Http
|
|
114
|
+
$handler = New-Object Net.Http.HttpClientHandler
|
|
115
|
+
$handler.AllowAutoRedirect = $false
|
|
116
|
+
$client = New-Object Net.Http.HttpClient($handler)
|
|
117
|
+
$client.Timeout = [TimeSpan]::FromSeconds(90)
|
|
118
|
+
$client.DefaultRequestHeaders.UserAgent.ParseAdd('Freoa-Converter-OCR/6.0rc3')
|
|
119
|
+
$partial = Join-Path $Cache ('.download-' + [Guid]::NewGuid().ToString('N') + '.part')
|
|
120
|
+
$response = $null
|
|
121
|
+
$output = $null
|
|
122
|
+
try {
|
|
123
|
+
for ($redirect = 0; $redirect -le 5; $redirect++) {
|
|
124
|
+
if ($uri.Scheme -ne 'https' -or $uri.Host -notin $allowed -or $uri.UserInfo) { throw 'OCR: redirect HTTPS invalido' }
|
|
125
|
+
$response = $client.GetAsync($uri, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
|
|
126
|
+
$code = [int]$response.StatusCode
|
|
127
|
+
if ($code -in @(301, 302, 303, 307, 308)) {
|
|
128
|
+
if ($redirect -eq 5 -or -not $response.Headers.Location) { throw 'OCR: excesso de redirects' }
|
|
129
|
+
$uri = New-Object Uri($uri, $response.Headers.Location)
|
|
130
|
+
$response.Dispose(); $response = $null
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
$response.EnsureSuccessStatusCode() | Out-Null
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
$contentLength = $response.Content.Headers.ContentLength
|
|
137
|
+
if ($contentLength -and $contentLength -ne [long]$Asset.bytes) { throw 'OCR: tamanho HTTP diverge do lock' }
|
|
138
|
+
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
|
|
139
|
+
try {
|
|
140
|
+
$output = [IO.File]::Open($partial, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
141
|
+
$buffer = New-Object byte[] 65536
|
|
142
|
+
[long]$total = 0
|
|
143
|
+
$deadline = [DateTime]::UtcNow.AddSeconds(180)
|
|
144
|
+
while ($true) {
|
|
145
|
+
$readTask = $input.ReadAsync($buffer, 0, $buffer.Length)
|
|
146
|
+
if (-not $readTask.Wait(90000)) { throw 'OCR: download sem progresso no prazo' }
|
|
147
|
+
$read = $readTask.GetAwaiter().GetResult()
|
|
148
|
+
if ($read -eq 0) { break }
|
|
149
|
+
$total += $read
|
|
150
|
+
if ($total -gt [long]$Asset.bytes -or [DateTime]::UtcNow -gt $deadline) { throw 'OCR: download excede limite/prazo' }
|
|
151
|
+
$output.Write($buffer, 0, $read)
|
|
152
|
+
}
|
|
153
|
+
$output.Dispose(); $output = $null
|
|
154
|
+
} finally { $input.Dispose() }
|
|
155
|
+
$checked = Get-OcrLockedFile $partial $Asset
|
|
156
|
+
$checked.Handle.Dispose()
|
|
157
|
+
# No overwrite: another bootstrap may have populated this cache meanwhile.
|
|
158
|
+
[IO.File]::Move($partial, $target)
|
|
159
|
+
return Get-OcrLockedFile $target $Asset
|
|
160
|
+
} finally {
|
|
161
|
+
if ($output) { $output.Dispose() }
|
|
162
|
+
if ($response) { $response.Dispose() }
|
|
163
|
+
$client.Dispose(); $handler.Dispose()
|
|
164
|
+
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function Invoke-OcrPrivateTool([string]$Executable, [string[]]$Arguments, [string]$WorkingDirectory, [int]$TimeoutSeconds = 60) {
|
|
169
|
+
$info = New-Object Diagnostics.ProcessStartInfo
|
|
170
|
+
$info.FileName = $Executable
|
|
171
|
+
# Windows file names cannot contain quotes. None of our arguments ends in a
|
|
172
|
+
# backslash; paths are complete files or non-root directory names.
|
|
173
|
+
if (@($Arguments | Where-Object { $_.Contains('"') -or $_.EndsWith('\') }).Count) { throw 'OCR: argumento nativo invalido' }
|
|
174
|
+
$info.Arguments = (($Arguments | ForEach-Object { '"' + $_ + '"' }) -join ' ')
|
|
175
|
+
$info.WorkingDirectory = $WorkingDirectory
|
|
176
|
+
$info.UseShellExecute = $false; $info.CreateNoWindow = $true
|
|
177
|
+
$info.RedirectStandardOutput = $true; $info.RedirectStandardError = $true
|
|
178
|
+
$info.EnvironmentVariables['TESSDATA_PREFIX'] = (Join-Path $WorkingDirectory 'tessdata')
|
|
179
|
+
$process = New-Object Diagnostics.Process
|
|
180
|
+
$process.StartInfo = $info
|
|
181
|
+
try {
|
|
182
|
+
if (-not $process.Start()) { throw 'OCR: ferramenta nao iniciou' }
|
|
183
|
+
$stdout = $process.StandardOutput.ReadToEndAsync()
|
|
184
|
+
$stderr = $process.StandardError.ReadToEndAsync()
|
|
185
|
+
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
|
186
|
+
# Only our newly-created private process tree; no installer execution.
|
|
187
|
+
& "$env:SystemRoot\System32\taskkill.exe" /PID $process.Id /T /F 2>&1 | Out-Null
|
|
188
|
+
$process.WaitForExit(5000) | Out-Null
|
|
189
|
+
throw 'OCR: ferramenta excedeu prazo'
|
|
190
|
+
}
|
|
191
|
+
$out = $stdout.GetAwaiter().GetResult(); $err = $stderr.GetAwaiter().GetResult()
|
|
192
|
+
if ($out.Length + $err.Length -gt 1000000) { throw 'OCR: log de ferramenta excedeu limite' }
|
|
193
|
+
if ($process.ExitCode -ne 0) { throw "OCR: ferramenta falhou ($($process.ExitCode)): $err" }
|
|
194
|
+
return [pscustomobject]@{ ExitCode = $process.ExitCode; Stdout = $out; Stderr = $err }
|
|
195
|
+
} finally { $process.Dispose() }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Install-FreoaOcr {
|
|
199
|
+
[CmdletBinding()]
|
|
200
|
+
param([Parameter(Mandatory)][string]$Destination,
|
|
201
|
+
[string[]]$Languages = @('por', 'eng', 'spa', 'ita', 'fra', 'deu'),
|
|
202
|
+
[string]$CacheDirectory, [Parameter(Mandatory)][string]$LockPath, [switch]$Offline)
|
|
203
|
+
if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { throw 'OCR: requer Windows x64' }
|
|
204
|
+
if (-not [IO.Path]::IsPathRooted($Destination)) { throw 'OCR: destino deve ser absoluto' }
|
|
205
|
+
$destinationFull = [IO.Path]::GetFullPath($Destination).TrimEnd('\', '/')
|
|
206
|
+
$parent = [IO.Path]::GetDirectoryName($destinationFull)
|
|
207
|
+
if (-not $parent -or -not [IO.Directory]::Exists($parent) -or $destinationFull -eq [IO.Path]::GetPathRoot($destinationFull).TrimEnd('\')) {
|
|
208
|
+
throw 'OCR: destino requer pai existente e diretorio novo'
|
|
209
|
+
}
|
|
210
|
+
Assert-OcrNoLinks $destinationFull
|
|
211
|
+
if (Get-Item -LiteralPath $destinationFull -Force -ErrorAction SilentlyContinue) { throw 'OCR: destino ja existe; nao reutilizado' }
|
|
212
|
+
$lockBytes = [IO.File]::ReadAllBytes($LockPath)
|
|
213
|
+
if ($lockBytes.Length -gt 1000000) { throw 'OCR: lock excede limite' }
|
|
214
|
+
$lockText = [Text.Encoding]::UTF8.GetString($lockBytes).TrimStart([char]0xFEFF)
|
|
215
|
+
$lock = $lockText | ConvertFrom-Json
|
|
216
|
+
$lockAlgorithm = [Security.Cryptography.SHA256]::Create()
|
|
217
|
+
try { $lockHash = [BitConverter]::ToString($lockAlgorithm.ComputeHash($lockBytes)).Replace('-', '').ToLowerInvariant() }
|
|
218
|
+
finally { $lockAlgorithm.Dispose() }
|
|
219
|
+
if ($lock.schema_version -ne 1 -or $lock.platform -ne 'windows-x64' -or $lock.engine_version -ne '5.5.3') { throw 'OCR: lock incompativel' }
|
|
220
|
+
# Python/native argv transports arrays as one comma/plus-separated value.
|
|
221
|
+
$Languages = @($Languages | ForEach-Object { $_ -split '[,+]' } | ForEach-Object { $_.Trim().ToLowerInvariant() })
|
|
222
|
+
if (-not $Languages.Count -or @($Languages | Select-Object -Unique).Count -ne $Languages.Count -or @($Languages | Where-Object { $_ -notin $lock.default_languages }).Count) {
|
|
223
|
+
throw 'OCR: idiomas invalidos; use por, eng, spa, ita, fra ou deu'
|
|
224
|
+
}
|
|
225
|
+
foreach ($member in @($lock.seven_zip_members) + @($lock.tesseract_members)) { Assert-OcrMemberName $member.path }
|
|
226
|
+
$cache = if ($CacheDirectory) { [IO.Path]::GetFullPath($CacheDirectory) } else { Join-Path $parent '.ocr-download-cache' }
|
|
227
|
+
if ($cache -eq $destinationFull -or $cache.StartsWith($destinationFull + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'OCR: cache deve ficar fora do destino' }
|
|
228
|
+
Assert-OcrNoLinks $cache
|
|
229
|
+
if (-not [IO.Directory]::Exists($cache)) { New-Item -ItemType Directory -Path $cache | Out-Null }
|
|
230
|
+
$held = New-Object Collections.Generic.List[object]
|
|
231
|
+
$created = $false
|
|
232
|
+
try {
|
|
233
|
+
$assets = @{}
|
|
234
|
+
$wanted = @('tesseract-ocr-w64-setup-5.5.3.20260724.exe', '7zr.exe', '7z2603-x64.exe', 'TESSDATA-LICENSE.txt') + @($Languages | ForEach-Object { $_ + '.traineddata' })
|
|
235
|
+
foreach ($name in $wanted) {
|
|
236
|
+
$matches = @($lock.assets | Where-Object { $_.name -eq $name })
|
|
237
|
+
if ($matches.Count -ne 1) { throw "OCR: asset unico ausente no lock: $name" }
|
|
238
|
+
$asset = Save-OcrPinnedAsset $matches[0] $cache $Offline.IsPresent
|
|
239
|
+
$held.Add($asset); $assets[$name] = $asset.Path
|
|
240
|
+
}
|
|
241
|
+
# Reserve atomically after all downloads verified; any preexisting path fails.
|
|
242
|
+
New-Item -ItemType Directory -Path $destinationFull -ErrorAction Stop | Out-Null
|
|
243
|
+
$created = $true
|
|
244
|
+
$scratch = Join-Path $destinationFull '.bootstrap-tools'
|
|
245
|
+
New-Item -ItemType Directory -Path $scratch | Out-Null
|
|
246
|
+
$privateInputs = Join-Path $scratch 'inputs'
|
|
247
|
+
New-Item -ItemType Directory -Path $privateInputs | Out-Null
|
|
248
|
+
$privateAssets = @{}
|
|
249
|
+
foreach ($name in @('7zr.exe', '7z2603-x64.exe', 'tesseract-ocr-w64-setup-5.5.3.20260724.exe')) {
|
|
250
|
+
# Read from the authenticated, write/delete-denying handle. Native
|
|
251
|
+
# programs only consume fresh private copies, never OneDrive inputs.
|
|
252
|
+
$source = @($held | Where-Object { $_.Path -eq $assets[$name] })[0]
|
|
253
|
+
$copy = Join-Path $privateInputs $name
|
|
254
|
+
$output = [IO.File]::Open($copy, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
255
|
+
try { $source.Handle.Position = 0; $source.Handle.CopyTo($output) }
|
|
256
|
+
finally { $output.Dispose(); $source.Handle.Position = 0 }
|
|
257
|
+
$specification = @($lock.assets | Where-Object { $_.name -eq $name })[0]
|
|
258
|
+
$bound = Get-OcrLockedFile $copy $specification
|
|
259
|
+
$held.Add($bound); $privateAssets[$name] = $copy
|
|
260
|
+
}
|
|
261
|
+
$sevenArgs = @('x', $privateAssets['7z2603-x64.exe'], '-y', '-bd', '-bso0', '-bsp0', ('-o' + $scratch), '--') + @($lock.seven_zip_members | ForEach-Object { $_.path })
|
|
262
|
+
Invoke-OcrPrivateTool $privateAssets['7zr.exe'] $sevenArgs $scratch | Out-Null
|
|
263
|
+
foreach ($member in $lock.seven_zip_members) { $bound = Get-OcrLockedFile (Join-Path $scratch $member.path) $member; $held.Add($bound) }
|
|
264
|
+
$engineArgs = @('x', $privateAssets['tesseract-ocr-w64-setup-5.5.3.20260724.exe'], '-y', '-bd', '-bso0', '-bsp0', ('-o' + $destinationFull), '--') + @($lock.tesseract_members | ForEach-Object { $_.path })
|
|
265
|
+
Invoke-OcrPrivateTool (Join-Path $scratch '7z.exe') $engineArgs $scratch | Out-Null
|
|
266
|
+
foreach ($member in $lock.tesseract_members) { $bound = Get-OcrLockedFile (Join-Path $destinationFull $member.path) $member; $held.Add($bound) }
|
|
267
|
+
foreach ($language in $Languages) { Copy-Item -LiteralPath $assets[($language + '.traineddata')] -Destination (Join-Path $destinationFull ('tessdata/' + $language + '.traineddata')) }
|
|
268
|
+
Copy-Item -LiteralPath $assets['TESSDATA-LICENSE.txt'] -Destination (Join-Path $destinationFull 'doc/TESSDATA-LICENSE.txt')
|
|
269
|
+
Copy-Item -LiteralPath (Join-Path $scratch 'License.txt') -Destination (Join-Path $destinationFull 'doc/7ZIP-LICENSE.txt')
|
|
270
|
+
foreach ($language in $Languages) {
|
|
271
|
+
$spec = @($lock.assets | Where-Object { $_.name -eq ($language + '.traineddata') })[0]
|
|
272
|
+
$bound = Get-OcrLockedFile (Join-Path $destinationFull ('tessdata/' + $language + '.traineddata')) $spec; $held.Add($bound)
|
|
273
|
+
}
|
|
274
|
+
$exe = Join-Path $destinationFull 'tesseract.exe'; $tessdata = Join-Path $destinationFull 'tessdata'
|
|
275
|
+
$version = Invoke-OcrPrivateTool $exe @('--version') $destinationFull 15
|
|
276
|
+
if ($version.Stdout -notmatch '(?m)^tesseract v?5\.5\.3(?:\.|\s|$)') { throw 'OCR: versao inesperada' }
|
|
277
|
+
$listed = Invoke-OcrPrivateTool $exe @('--tessdata-dir', $tessdata, '--list-langs') $destinationFull 15
|
|
278
|
+
$available = @($listed.Stdout -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[a-z]{3}$' })
|
|
279
|
+
if (@($Languages | Where-Object { $_ -notin $available }).Count) { throw 'OCR: idioma instalado nao listado pelo motor' }
|
|
280
|
+
$receipt = [ordered]@{ schema_version = 1; version = $version.Stdout.Split("`n")[0].Trim(); languages = $Languages; lock_sha256 = $lockHash; installers_executed = $false; global_environment_changed = $false }
|
|
281
|
+
$receipt | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $destinationFull 'ocr-runtime.json') -Encoding UTF8
|
|
282
|
+
foreach ($entry in $held) { $entry.Handle.Dispose() }; $held.Clear()
|
|
283
|
+
Assert-OcrNoLinks $scratch
|
|
284
|
+
if ([IO.Path]::GetFullPath($scratch) -ne (Join-Path $destinationFull '.bootstrap-tools')) { throw 'OCR: cleanup fora do stage' }
|
|
285
|
+
Remove-Item -LiteralPath $scratch -Recurse -Force
|
|
286
|
+
return [pscustomobject]@{ Tesseract = $exe; Tessdata = $tessdata; Languages = $Languages; Version = $receipt.version; Ready = $true; CacheDirectory = $cache }
|
|
287
|
+
} catch {
|
|
288
|
+
foreach ($entry in $held) { $entry.Handle.Dispose() }; $held.Clear()
|
|
289
|
+
if ($created) {
|
|
290
|
+
# Only the directory this call reserved; never a reused installation.
|
|
291
|
+
Assert-OcrNoLinks $destinationFull
|
|
292
|
+
if ([IO.Path]::GetDirectoryName($destinationFull) -ne $parent) { throw 'OCR: cleanup fora do pai reservado' }
|
|
293
|
+
$links = @(Get-ChildItem -LiteralPath $destinationFull -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
|
|
294
|
+
if ($links.Count) { throw 'OCR: cleanup recusado por link inesperado; stage preservado' }
|
|
295
|
+
Remove-Item -LiteralPath $destinationFull -Recurse -Force
|
|
296
|
+
}
|
|
297
|
+
throw
|
|
298
|
+
} finally { foreach ($entry in $held) { $entry.Handle.Dispose() } }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if ($Destination) {
|
|
302
|
+
Install-FreoaOcr -Destination $Destination -Languages $Languages -CacheDirectory $CacheDirectory -LockPath $LockPath -Offline:$Offline
|
|
303
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
%PDF-1.4
|
|
2
|
+
%����
|
|
3
|
+
1 0 obj
|
|
4
|
+
<< /Type /Catalog /Pages 2 0 R >>
|
|
5
|
+
endobj
|
|
6
|
+
2 0 obj
|
|
7
|
+
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
|
8
|
+
endobj
|
|
9
|
+
3 0 obj
|
|
10
|
+
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>
|
|
11
|
+
endobj
|
|
12
|
+
4 0 obj
|
|
13
|
+
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
|
14
|
+
endobj
|
|
15
|
+
5 0 obj
|
|
16
|
+
<< /Length 73 >>
|
|
17
|
+
stream
|
|
18
|
+
BT /F1 12 Tf 72 720 Td (Traslado verificacao de instalacao PDF OK.) Tj ET
|
|
19
|
+
endstream
|
|
20
|
+
endobj
|
|
21
|
+
xref
|
|
22
|
+
0 6
|
|
23
|
+
0000000000 65535 f
|
|
24
|
+
0000000015 00000 n
|
|
25
|
+
0000000064 00000 n
|
|
26
|
+
0000000121 00000 n
|
|
27
|
+
0000000247 00000 n
|
|
28
|
+
0000000317 00000 n
|
|
29
|
+
trailer
|
|
30
|
+
<< /Size 6 /Root 1 0 R >>
|
|
31
|
+
startxref
|
|
32
|
+
440
|
|
33
|
+
%%EOF
|