forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathvision_html_export.py
More file actions
264 lines (220 loc) · 9.19 KB
/
Copy pathvision_html_export.py
File metadata and controls
264 lines (220 loc) · 9.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Export Docling / Paddle vision OCR results to HTML for LO import."""
from __future__ import annotations
import html as html_module
import importlib
import logging
import re
from typing import Any
log = logging.getLogger(__name__)
CSS_INLINE_INSTALL_CMD = "pip install css-inline"
# Docling inlines h2-h6 with color/margins only; StarWriter needs explicit size/weight.
_LO_HEADING_INLINE: dict[str, str] = {
"h1": "font-size: 18pt; font-weight: bold;",
"h2": "font-size: 14pt; font-weight: bold;",
"h3": "font-size: 12pt; font-weight: bold;",
"h4": "font-size: 11pt; font-weight: bold;",
"h5": "font-size: 10pt; font-weight: bold;",
"h6": "font-size: 10pt; font-weight: bold; font-style: italic;",
}
_HEADING_TAG_RE = re.compile(r"<(h[1-6])(\s[^>]*)?>", re.IGNORECASE)
_PLAIN_P_TAG_RE = re.compile(r"<p(?![^>]*\bstyle\s*=)(\s[^>]*)?>", re.IGNORECASE)
_LO_BODY_PARAGRAPH_INLINE = "font-family: Arial, sans-serif; line-height: 1.6;"
# Minimal stylesheet for Paddle-built fragments before css-inline hoists rules.
_PADDLE_HTML_STYLE = """<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
h2 { font-size: 1.25em; font-weight: bold; margin: 0.75em 0 0.35em; }
p { margin: 0.35em 0; }
table { border-collapse: collapse; margin: 0.5em 0; width: 100%; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background-color: #f2f2f2; font-weight: bold; }
</style>"""
def _merge_inline_style_on_open_tag(match: re.Match[str], extra: str) -> str:
tag = match.group(1).lower()
attrs = match.group(2) or ""
if re.search(r'style\s*=\s*"', attrs, re.IGNORECASE):
return re.sub(
r'(style\s*=\s*")',
lambda style_match: f"{style_match.group(1)}{extra}",
f"<{tag}{attrs}>",
count=1,
flags=re.IGNORECASE,
)
return f'<{tag} style="{extra}"{attrs}>'
def augment_lo_heading_styles(html: str) -> str:
"""Merge bold/font-size into heading tags — Docling CSS leaves h2 looking like body text."""
def _repl(match: re.Match[str]) -> str:
tag = match.group(1).lower()
extra = _LO_HEADING_INLINE.get(tag, "")
if not extra:
return match.group(0)
return _merge_inline_style_on_open_tag(match, extra)
return _HEADING_TAG_RE.sub(_repl, html)
def augment_lo_body_paragraph_styles(html: str) -> str:
"""Add Arial/line-height on bare <p> tags — Docling body lines have no inline styles."""
def _repl(match: re.Match[str]) -> str:
attrs = match.group(1) or ""
return f'<p style="{_LO_BODY_PARAGRAPH_INLINE}"{attrs}>'
return _PLAIN_P_TAG_RE.sub(_repl, html)
def prepare_html_for_lo_import(html: str) -> str:
"""Inline CSS so LibreOffice HTML (StarWriter) import keeps typography."""
try:
import css_inline
except ImportError as exc:
import sys
raise ImportError(
f"No module named 'css_inline' in {sys.executable}. "
f"Install in your Settings → Python venv: {sys.executable} -m pip install css-inline"
) from exc
stripped = (html or "").strip()
if not stripped:
return html or ""
inlined = css_inline.inline(stripped)
with_headings = augment_lo_heading_styles(inlined)
return augment_lo_body_paragraph_styles(with_headings)
def _wrap_paddle_fragment(body: str) -> str:
return (
"<!DOCTYPE html><html><head><meta charset=\"UTF-8\">"
f"{_PADDLE_HTML_STYLE}</head><body>{body}</body></html>"
)
def export_docling_to_html(document: Any, params: dict[str, Any]) -> str:
"""Return rich HTML from a DoclingDocument (bold, tables, headings), css-inlined for LO."""
del params # reserved for future Docling HTML export options
docling_doc_mod = importlib.import_module("docling_core.types.doc")
image_ref_mode = docling_doc_mod.ImageRefMode
if hasattr(document, "export_to_html"):
raw = str(
document.export_to_html(
image_mode=image_ref_mode.EMBEDDED,
split_page_view=False,
)
or ""
)
return prepare_html_for_lo_import(raw)
return ""
def html_from_paddle_regions(regions: list[dict[str, Any]]) -> str:
"""Minimal HTML from Paddle OCR line regions (reading order), css-inlined for LO."""
parts: list[str] = []
for region in regions:
if not isinstance(region, dict):
continue
text = str(region.get("text") or "").strip()
if text:
parts.append(f"<p>{html_module.escape(text)}</p>")
if not parts:
return ""
return prepare_html_for_lo_import(_wrap_paddle_fragment("\n".join(parts)))
def _paddle_block_tag(block_type: str) -> str:
label = block_type.strip().lower()
if label in ("title", "section_header", "header", "heading"):
return "h2"
if label in ("caption", "footnote"):
return "p"
return "p"
def _html_table_from_columns_rows(columns: list[Any], rows: list[list[Any]]) -> str:
if not columns and not rows:
return ""
lines = ["<table>"]
if columns:
lines.append("<thead><tr>")
for col in columns:
lines.append(f"<th>{html_module.escape(str(col))}</th>")
lines.append("</tr></thead>")
if rows:
lines.append("<tbody>")
for row in rows:
if not isinstance(row, list):
continue
lines.append("<tr>")
for cell in row:
lines.append(f"<td>{html_module.escape(str(cell))}</td>")
lines.append("</tr>")
lines.append("</tbody>")
lines.append("</table>")
return "".join(lines)
def html_from_paddle_structure(
blocks: list[dict[str, Any]],
tables: list[dict[str, Any]],
) -> str:
"""Build HTML from Paddle PP-Structure blocks and parsed tables, css-inlined for LO."""
parts: list[str] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_type = str(block.get("type") or "text")
text = str(block.get("text") or "").strip()
if block_type == "table" and not text:
continue
tag = _paddle_block_tag(block_type)
if not text:
continue
if tag == "h2":
parts.append(f"<h2>{html_module.escape(text)}</h2>")
else:
parts.append(f"<p>{html_module.escape(text)}</p>")
for table in tables:
if not isinstance(table, dict):
continue
table_html = _html_table_from_columns_rows(
list(table.get("columns") or []),
[list(r) for r in (table.get("rows") or []) if isinstance(r, list)],
)
if table_html:
parts.append(table_html)
if not parts:
return ""
return prepare_html_for_lo_import(_wrap_paddle_fragment("\n".join(parts)))
def _blocks_from_vision_result(result: dict[str, Any]) -> list[dict[str, Any]]:
blocks = result.get("blocks")
if isinstance(blocks, list) and blocks:
return [block for block in blocks if isinstance(block, dict)]
regions = result.get("regions")
if not isinstance(regions, list):
return []
out: list[dict[str, Any]] = []
for region in regions:
if not isinstance(region, dict):
continue
text = str(region.get("text") or "").strip()
if not text:
continue
out.append({"type": "text", "text": text, "box": region.get("box") or [0, 0, 0, 0]})
return out
def structured_html_from_vision_result(result: dict[str, Any]) -> str:
"""Build bbox layout HTML from blocks/regions; must run in the user venv (needs css-inline)."""
from plugin.vision.venv.vision_layout_html import html_from_layout_blocks
body = html_from_layout_blocks(_blocks_from_vision_result(result), {})
tables = result.get("tables")
if isinstance(tables, list):
for table in tables:
if not isinstance(table, dict):
continue
table_html = _html_table_from_columns_rows(
list(table.get("columns") or []),
[list(row) for row in (table.get("rows") or []) if isinstance(row, list)],
)
if table_html:
body = f"{body}\n{table_html}" if body else table_html
if not body.strip():
return ""
return prepare_html_for_lo_import(_wrap_paddle_fragment(body))
def apply_structured_insert_html(result: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
"""When insert_mode=structured, replace html in the worker before LO insert (css-inline lives in venv)."""
from plugin.vision.vision_common import DEFAULT_VISION_INSERT_MODE
if result.get("status") != "ok":
return result
helper = str(result.get("helper") or "")
if helper not in ("extract_text", "extract_structure"):
return result
mode = str(params.get("insert_mode") or DEFAULT_VISION_INSERT_MODE).strip().lower()
if mode != "structured":
return result
html = structured_html_from_vision_result(result)
if not html.strip():
return result
updated = dict(result)
updated["html"] = html
return updated