odt2web 0.8.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.
- odt2web/__init__.py +25 -0
- odt2web/__main__.py +4 -0
- odt2web/assets.py +82 -0
- odt2web/cli.py +139 -0
- odt2web/codeblocks.py +87 -0
- odt2web/config.py +74 -0
- odt2web/convert.py +186 -0
- odt2web/css.py +195 -0
- odt2web/extensibility.py +57 -0
- odt2web/frontmatter.py +103 -0
- odt2web/metadata.py +45 -0
- odt2web/model.py +207 -0
- odt2web/ns.py +29 -0
- odt2web/parser.py +560 -0
- odt2web/rawhtml.py +93 -0
- odt2web/reader.py +93 -0
- odt2web/renderer.py +438 -0
- odt2web/result.py +67 -0
- odt2web/security.py +78 -0
- odt2web/styles.py +410 -0
- odt2web/tables.py +46 -0
- odt2web-0.8.0.dist-info/METADATA +400 -0
- odt2web-0.8.0.dist-info/RECORD +26 -0
- odt2web-0.8.0.dist-info/WHEEL +5 -0
- odt2web-0.8.0.dist-info/entry_points.txt +2 -0
- odt2web-0.8.0.dist-info/top_level.txt +1 -0
odt2web/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""odt2web - Python library to convert ODT documents into HTML/CSS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .convert import check, convert, convert_bytes
|
|
6
|
+
from .extensibility import get_default_renderers, register_renderer, unregister_renderer
|
|
7
|
+
from .model import Document, Metadata
|
|
8
|
+
from .reader import InvalidOdtError, OdtPackage, read_odt_bytes, read_odt_file
|
|
9
|
+
from .result import Asset, ConversionResult, Warning, WarningLevel
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"convert",
|
|
13
|
+
"convert_bytes",
|
|
14
|
+
"check",
|
|
15
|
+
"ConversionResult",
|
|
16
|
+
"Asset",
|
|
17
|
+
"Metadata",
|
|
18
|
+
"Warning",
|
|
19
|
+
"WarningLevel",
|
|
20
|
+
"InvalidOdtError",
|
|
21
|
+
"register_renderer",
|
|
22
|
+
"unregister_renderer",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.8.0"
|
odt2web/__main__.py
ADDED
odt2web/assets.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Asset extraction."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import posixpath
|
|
7
|
+
|
|
8
|
+
from .model import Document, Image
|
|
9
|
+
from .reader import OdtPackage
|
|
10
|
+
from .result import Asset
|
|
11
|
+
from .security import is_safe_relative_path
|
|
12
|
+
|
|
13
|
+
_MEDIA_TYPE_BY_EXT = {
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".bmp": "image/bmp",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
".tif": "image/tiff",
|
|
22
|
+
".tiff": "image/tiff",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _guess_media_type(path: str) -> str | None:
|
|
27
|
+
ext = posixpath.splitext(path)[1].lower()
|
|
28
|
+
return _MEDIA_TYPE_BY_EXT.get(ext)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_assets(document: Document, package: OdtPackage, assets_dir: str = "assets") -> list[Asset]:
|
|
32
|
+
|
|
33
|
+
assets: list[Asset] = []
|
|
34
|
+
seen: dict[str, str] = {} # original_path -> output_path (avoids duplicates)
|
|
35
|
+
counter = 1
|
|
36
|
+
|
|
37
|
+
def visit_images(nodes):
|
|
38
|
+
nonlocal counter
|
|
39
|
+
for node in nodes:
|
|
40
|
+
if isinstance(node, Image) and not node.linked:
|
|
41
|
+
original = node.src
|
|
42
|
+
if not original or not is_safe_relative_path(original):
|
|
43
|
+
continue
|
|
44
|
+
if original not in package.resources:
|
|
45
|
+
continue # missing/invalid resource
|
|
46
|
+
if original in seen:
|
|
47
|
+
node.src = seen[original]
|
|
48
|
+
continue
|
|
49
|
+
ext = posixpath.splitext(original)[1] or ".bin"
|
|
50
|
+
output_name = f"image-{counter:03d}{ext}"
|
|
51
|
+
counter += 1
|
|
52
|
+
output_path = posixpath.join(assets_dir, output_name)
|
|
53
|
+
data = package.resources[original]
|
|
54
|
+
assets.append(
|
|
55
|
+
Asset(
|
|
56
|
+
original_path=original,
|
|
57
|
+
output_path=output_path,
|
|
58
|
+
data=data,
|
|
59
|
+
media_type=_guess_media_type(original),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
seen[original] = output_path
|
|
63
|
+
node.src = output_path
|
|
64
|
+
children = getattr(node, "children", None)
|
|
65
|
+
if children:
|
|
66
|
+
visit_images(children)
|
|
67
|
+
items = getattr(node, "items", None)
|
|
68
|
+
if items:
|
|
69
|
+
for item in items:
|
|
70
|
+
visit_images(item.children)
|
|
71
|
+
rows = getattr(node, "rows", None)
|
|
72
|
+
if rows:
|
|
73
|
+
for row in rows:
|
|
74
|
+
for cell in row.cells:
|
|
75
|
+
visit_images(cell.children)
|
|
76
|
+
|
|
77
|
+
for section in document.sections:
|
|
78
|
+
visit_images(section.children)
|
|
79
|
+
for note in document.notes.values():
|
|
80
|
+
visit_images(note.children)
|
|
81
|
+
|
|
82
|
+
return assets
|
odt2web/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Command-line interface (spec section 15).
|
|
2
|
+
|
|
3
|
+
odt2web artigo.odt
|
|
4
|
+
odt2web artigo.odt -o dist/
|
|
5
|
+
odt2web artigo.odt --css
|
|
6
|
+
odt2web artigo.odt --assets assets/
|
|
7
|
+
odt2web artigo.odt --fragment
|
|
8
|
+
odt2web artigo.odt --config config.yaml
|
|
9
|
+
odt2web --check artigo.odt
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from . import __version__, check, convert
|
|
17
|
+
from .reader import InvalidOdtError
|
|
18
|
+
from .result import WarningLevel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="odt2web",
|
|
24
|
+
description="Converts OpenDocument Text (.odt) documents to HTML/CSS.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("input", help="Path to the .odt file")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"-o", "--output-dir", dest="output_dir", default=None,
|
|
29
|
+
help="Output directory (saves index.html, style.css and assets/)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--css", dest="css", action=argparse.BooleanOptionalAction, default=None,
|
|
33
|
+
help="Automatically generates CSS (default: enabled; use --no-css to disable)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--extract-images", dest="extract_images", action=argparse.BooleanOptionalAction,
|
|
37
|
+
default=None, help="Extract embedded images (default: enabled)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--assets", dest="assets_dir", default=None,
|
|
41
|
+
help="Subdirectory for extracted assets (default: assets)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--allow-raw-html", dest="allow_raw_html", action=argparse.BooleanOptionalAction,
|
|
45
|
+
default=None,
|
|
46
|
+
help="Outputs ':::html' ... ':::' blocks as literal HTML, without escaping. "
|
|
47
|
+
"(default: disabled – only enable for documents from trusted authors)",
|
|
48
|
+
)
|
|
49
|
+
doc_group = parser.add_mutually_exclusive_group()
|
|
50
|
+
doc_group.add_argument(
|
|
51
|
+
"--fragment", dest="document", action="store_false", default=None,
|
|
52
|
+
help="Generates only the HTML fragment (without doctype/html/head/body); "
|
|
53
|
+
"without this option, a complete HTML document is generated by default.",
|
|
54
|
+
)
|
|
55
|
+
doc_group.add_argument(
|
|
56
|
+
"--document", dest="document", action="store_true", default=None,
|
|
57
|
+
help="Forces the generation of a complete HTML document (CLI default)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--config", dest="config", default=None,
|
|
61
|
+
help="YAML configuration file",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--check", dest="check_only", action="store_true",
|
|
65
|
+
help="It only validates the document, without generating HTML.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--html-filename", dest="html_filename", default="index.html",
|
|
69
|
+
help="Name of the generated HTML file within --output-dir (default: index.html)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--css-filename", dest="css_filename", default="style.css",
|
|
73
|
+
help="Name of the generated CSS file within --output-dir (default: style.css)",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--version", action="version", version=f"odt2web {__version__}")
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_warnings(warnings, *, file=sys.stderr) -> None:
|
|
80
|
+
for warning in warnings:
|
|
81
|
+
prefix = {
|
|
82
|
+
WarningLevel.INFO: "info",
|
|
83
|
+
WarningLevel.WARNING: "warning",
|
|
84
|
+
WarningLevel.ERROR: "error",
|
|
85
|
+
}.get(warning.level, "warning")
|
|
86
|
+
source = f" ({warning.source})" if warning.source else ""
|
|
87
|
+
print(f"[{prefix}]{source} {warning.message}", file=file)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
parser = build_parser()
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
if args.check_only:
|
|
96
|
+
warnings = check(args.input)
|
|
97
|
+
_print_warnings(warnings, file=sys.stdout)
|
|
98
|
+
errors = [w for w in warnings if w.level == WarningLevel.ERROR]
|
|
99
|
+
print(f"OK: {args.input} is a valid .odt document."
|
|
100
|
+
f"({len(warnings)} warning(s))")
|
|
101
|
+
return 1 if errors else 0
|
|
102
|
+
|
|
103
|
+
kwargs: dict = {"output_dir": args.output_dir, "config": args.config}
|
|
104
|
+
if args.css is not None:
|
|
105
|
+
kwargs["css"] = args.css
|
|
106
|
+
if args.extract_images is not None:
|
|
107
|
+
kwargs["extract_images"] = args.extract_images
|
|
108
|
+
if args.assets_dir is not None:
|
|
109
|
+
kwargs["assets_dir"] = args.assets_dir
|
|
110
|
+
if args.allow_raw_html is not None:
|
|
111
|
+
kwargs["allow_raw_html"] = args.allow_raw_html
|
|
112
|
+
# unlike the API, the CLI generates a full document by default
|
|
113
|
+
# (more useful for standalone use); --fragment/--document override this.
|
|
114
|
+
kwargs["document"] = args.document if args.document is not None else True
|
|
115
|
+
|
|
116
|
+
result = convert(args.input, **kwargs)
|
|
117
|
+
except InvalidOdtError as exc:
|
|
118
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
|
|
124
|
+
if args.output_dir:
|
|
125
|
+
result.write(args.output_dir, html_filename=args.html_filename,
|
|
126
|
+
css_filename=args.css_filename)
|
|
127
|
+
print(f"Saved in {args.output_dir}/{args.html_filename}"
|
|
128
|
+
+ (f" e {args.output_dir}/{args.css_filename}" if result.css else ""))
|
|
129
|
+
else:
|
|
130
|
+
sys.stdout.write(result.html)
|
|
131
|
+
if not result.html.endswith("\n"):
|
|
132
|
+
sys.stdout.write("\n")
|
|
133
|
+
|
|
134
|
+
_print_warnings(result.warnings)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__": # pragma: no cover
|
|
139
|
+
raise SystemExit(main())
|
odt2web/codeblocks.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Code blocks.
|
|
2
|
+
"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import CodeBlock, LineBreak, Link, Paragraph, Section, Span, Text
|
|
6
|
+
from .styles import resolve_style_mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _literal_text(nodes: list) -> str:
|
|
10
|
+
"""Extracts the literal text of a paragraph, ignoring inline
|
|
11
|
+
formatting (bold/italic/links) but preserving manual line breaks as
|
|
12
|
+
'\\n' - used to avoid corrupting code."""
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
for node in nodes:
|
|
15
|
+
if isinstance(node, Text):
|
|
16
|
+
parts.append(node.value)
|
|
17
|
+
elif isinstance(node, (Span, Link)):
|
|
18
|
+
parts.append(_literal_text(node.children))
|
|
19
|
+
elif isinstance(node, LineBreak):
|
|
20
|
+
parts.append("\n")
|
|
21
|
+
return "".join(parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _strip_language_fence(lines: list[str]) -> tuple[str | None, list[str]]:
|
|
25
|
+
"""If the first line is a Markdown-style marker ('```python'),
|
|
26
|
+
removes it and returns the indicated language."""
|
|
27
|
+
if lines and lines[0].strip().startswith("```"):
|
|
28
|
+
lang = lines[0].strip()[3:].strip()
|
|
29
|
+
return (lang or None), lines[1:]
|
|
30
|
+
return None, lines
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _language_from_css_class(css_class: str | None) -> str | None:
|
|
34
|
+
if css_class and css_class.startswith("language-"):
|
|
35
|
+
return css_class[len("language-"):]
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge_children(children: list, style_map: dict) -> list:
|
|
40
|
+
result: list = []
|
|
41
|
+
i = 0
|
|
42
|
+
n = len(children)
|
|
43
|
+
|
|
44
|
+
while i < n:
|
|
45
|
+
node = children[i]
|
|
46
|
+
|
|
47
|
+
if isinstance(node, Paragraph):
|
|
48
|
+
mapping = resolve_style_mapping(node.style_candidates, style_map)
|
|
49
|
+
tag, attrs = mapping if mapping is not None else ("p", {})
|
|
50
|
+
if tag == "pre":
|
|
51
|
+
css_class = attrs.get("class")
|
|
52
|
+
j = i
|
|
53
|
+
paragraph_lines: list[str] = []
|
|
54
|
+
while j < n and isinstance(children[j], Paragraph):
|
|
55
|
+
m2 = resolve_style_mapping(children[j].style_candidates, style_map)
|
|
56
|
+
t2, a2 = m2 if m2 is not None else ("p", {})
|
|
57
|
+
if t2 != "pre" or a2.get("class") != css_class:
|
|
58
|
+
break
|
|
59
|
+
paragraph_lines.append(_literal_text(children[j].children))
|
|
60
|
+
j += 1
|
|
61
|
+
|
|
62
|
+
# each paragraph may contain multiple internal lines
|
|
63
|
+
# (manual line breaks); flatten everything into a list of
|
|
64
|
+
# lines before looking for the language marker.
|
|
65
|
+
lines: list[str] = []
|
|
66
|
+
for para_text in paragraph_lines:
|
|
67
|
+
lines.extend(para_text.split("\n"))
|
|
68
|
+
|
|
69
|
+
fence_lang, lines = _strip_language_fence(lines)
|
|
70
|
+
language = fence_lang or _language_from_css_class(css_class)
|
|
71
|
+
|
|
72
|
+
result.append(CodeBlock(
|
|
73
|
+
code="\n".join(lines), language=language, css_class=css_class,
|
|
74
|
+
))
|
|
75
|
+
i = j
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
result.append(node)
|
|
79
|
+
i += 1
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def merge_code_blocks(sections: list[Section], style_map: dict) -> None:
|
|
85
|
+
|
|
86
|
+
for section in sections:
|
|
87
|
+
section.children = _merge_children(section.children, style_map)
|
odt2web/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""YAML-based configuration.
|
|
2
|
+
|
|
3
|
+
Example of an accepted config file:
|
|
4
|
+
|
|
5
|
+
output:
|
|
6
|
+
document: false
|
|
7
|
+
css: true
|
|
8
|
+
assets: assets
|
|
9
|
+
allow_raw_html: false
|
|
10
|
+
|
|
11
|
+
columns:
|
|
12
|
+
enabled: true
|
|
13
|
+
|
|
14
|
+
styles:
|
|
15
|
+
Title: h1
|
|
16
|
+
Heading 1: h2
|
|
17
|
+
Heading 2: h3
|
|
18
|
+
Quotation: blockquote
|
|
19
|
+
Lead:
|
|
20
|
+
tag: p
|
|
21
|
+
class: lead
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config(path: str) -> dict:
|
|
33
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
34
|
+
try:
|
|
35
|
+
data = yaml.safe_load(fh)
|
|
36
|
+
except yaml.YAMLError as exc:
|
|
37
|
+
raise ConfigError(f"Error reading YAML config '{path}': {exc}") from exc
|
|
38
|
+
if data is None:
|
|
39
|
+
return {}
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
f"YAML config '{path}' must have a mapping at the root "
|
|
43
|
+
f"(e.g. 'output:', 'styles:'), not {type(data).__name__}"
|
|
44
|
+
)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def config_to_options(data: dict) -> dict:
|
|
49
|
+
"""Converts the YAML structure into a kwargs dict compatible with
|
|
50
|
+
the convert()/convert_bytes() signature."""
|
|
51
|
+
options: dict = {}
|
|
52
|
+
|
|
53
|
+
output = data.get("output") or {}
|
|
54
|
+
if "document" in output:
|
|
55
|
+
options["document"] = bool(output["document"])
|
|
56
|
+
if "css" in output:
|
|
57
|
+
options["css"] = bool(output["css"])
|
|
58
|
+
if "assets" in output:
|
|
59
|
+
options["assets_dir"] = str(output["assets"])
|
|
60
|
+
if "allow_raw_html" in output:
|
|
61
|
+
options["allow_raw_html"] = bool(output["allow_raw_html"])
|
|
62
|
+
if "extract_images" in output:
|
|
63
|
+
options["extract_images"] = bool(output["extract_images"])
|
|
64
|
+
|
|
65
|
+
columns = data.get("columns") or {}
|
|
66
|
+
if "enabled" in columns and not columns["enabled"]:
|
|
67
|
+
# disables column detection by forcing 1 column in every section
|
|
68
|
+
options["disable_columns"] = True
|
|
69
|
+
|
|
70
|
+
styles = data.get("styles")
|
|
71
|
+
if styles:
|
|
72
|
+
options["style_map"] = dict(styles)
|
|
73
|
+
|
|
74
|
+
return options
|
odt2web/convert.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""High-level conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from .assets import extract_assets
|
|
7
|
+
from .codeblocks import merge_code_blocks
|
|
8
|
+
from .css import generate_css
|
|
9
|
+
from .extensibility import get_default_renderers
|
|
10
|
+
from .metadata import parse_metadata
|
|
11
|
+
from .model import Document
|
|
12
|
+
from .parser import parse_document
|
|
13
|
+
from .reader import OdtPackage, read_odt_bytes, read_odt_file
|
|
14
|
+
from .renderer import render_document, wrap_full_document
|
|
15
|
+
from .result import ConversionResult, Warning, WarningLevel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _disable_columns(document: Document) -> None:
|
|
19
|
+
for section in document.sections:
|
|
20
|
+
section.columns = 1
|
|
21
|
+
section.column_gap = None
|
|
22
|
+
section.column_rule = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_result(
|
|
26
|
+
package: OdtPackage,
|
|
27
|
+
*,
|
|
28
|
+
css: bool = True,
|
|
29
|
+
extract_images: bool = True,
|
|
30
|
+
assets_dir: str = "assets",
|
|
31
|
+
document: bool = False,
|
|
32
|
+
style_map: dict | None = None,
|
|
33
|
+
custom_renderers: dict | None = None,
|
|
34
|
+
disable_columns: bool = False,
|
|
35
|
+
allow_raw_html: bool = False,
|
|
36
|
+
) -> ConversionResult:
|
|
37
|
+
doc_model = parse_document(package)
|
|
38
|
+
doc_model.metadata = parse_metadata(package.meta_xml, package.content_xml)
|
|
39
|
+
|
|
40
|
+
if disable_columns:
|
|
41
|
+
_disable_columns(doc_model)
|
|
42
|
+
|
|
43
|
+
merge_code_blocks(doc_model.sections, style_map or {})
|
|
44
|
+
|
|
45
|
+
if extract_images:
|
|
46
|
+
assets = extract_assets(doc_model, package, assets_dir=assets_dir)
|
|
47
|
+
else:
|
|
48
|
+
assets = []
|
|
49
|
+
doc_model.warnings.append(
|
|
50
|
+
"extract_images=False: images were not extracted; the "
|
|
51
|
+
"original paths from the ODT package were kept in the HTML "
|
|
52
|
+
"and may not resolve correctly"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
renderers = get_default_renderers()
|
|
56
|
+
if custom_renderers:
|
|
57
|
+
renderers.update(custom_renderers)
|
|
58
|
+
|
|
59
|
+
body_html, render_ctx = render_document(
|
|
60
|
+
doc_model, style_map=style_map or {}, custom_renderers=renderers,
|
|
61
|
+
allow_raw_html=allow_raw_html,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
css_text = generate_css(render_ctx) if css else None
|
|
65
|
+
|
|
66
|
+
if document:
|
|
67
|
+
html = wrap_full_document(body_html, css_text, doc_model.metadata)
|
|
68
|
+
else:
|
|
69
|
+
html = body_html
|
|
70
|
+
|
|
71
|
+
warnings = [Warning(message=w, level=WarningLevel.WARNING, source="parser")
|
|
72
|
+
for w in doc_model.warnings]
|
|
73
|
+
warnings += [Warning(message=w, level=WarningLevel.WARNING, source="renderer")
|
|
74
|
+
for w in render_ctx.warnings]
|
|
75
|
+
|
|
76
|
+
return ConversionResult(
|
|
77
|
+
html=html,
|
|
78
|
+
css=css_text,
|
|
79
|
+
assets=assets,
|
|
80
|
+
metadata=doc_model.metadata,
|
|
81
|
+
front_matter=doc_model.front_matter,
|
|
82
|
+
warnings=warnings,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# the library's "real" defaults, applied only after explicit arguments
|
|
87
|
+
# and the YAML config have already been considered (lets the config
|
|
88
|
+
# override the default when the caller doesn't pass the argument).
|
|
89
|
+
_LIBRARY_DEFAULTS = dict(
|
|
90
|
+
css=True, extract_images=True, assets_dir="assets", document=False,
|
|
91
|
+
allow_raw_html=False,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _merge_options(*, css, extract_images, assets_dir, document, style_map,
|
|
96
|
+
custom_renderers, allow_raw_html, config) -> tuple[dict, bool]:
|
|
97
|
+
from .config import config_to_options, load_config
|
|
98
|
+
|
|
99
|
+
# only arguments the caller actually passed (different from the
|
|
100
|
+
# None sentinel) go into the dict; the rest is left to the
|
|
101
|
+
# config/defaults.
|
|
102
|
+
explicit = {
|
|
103
|
+
"css": css, "extract_images": extract_images, "assets_dir": assets_dir,
|
|
104
|
+
"document": document, "allow_raw_html": allow_raw_html,
|
|
105
|
+
}
|
|
106
|
+
options: dict = {k: v for k, v in explicit.items() if v is not None}
|
|
107
|
+
if style_map is not None:
|
|
108
|
+
options["style_map"] = style_map
|
|
109
|
+
if custom_renderers is not None:
|
|
110
|
+
options["custom_renderers"] = custom_renderers
|
|
111
|
+
|
|
112
|
+
disable_columns = False
|
|
113
|
+
if config:
|
|
114
|
+
file_options = config_to_options(load_config(config))
|
|
115
|
+
for key, value in file_options.items():
|
|
116
|
+
if key == "style_map":
|
|
117
|
+
merged = dict(value)
|
|
118
|
+
merged.update(style_map or {})
|
|
119
|
+
options["style_map"] = merged
|
|
120
|
+
elif key == "disable_columns":
|
|
121
|
+
disable_columns = value
|
|
122
|
+
elif key not in options:
|
|
123
|
+
options[key] = value
|
|
124
|
+
|
|
125
|
+
for key, default in _LIBRARY_DEFAULTS.items():
|
|
126
|
+
options.setdefault(key, default)
|
|
127
|
+
|
|
128
|
+
return options, disable_columns
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def convert(
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
output_dir: str | None = None,
|
|
135
|
+
css: bool | None = None,
|
|
136
|
+
extract_images: bool | None = None,
|
|
137
|
+
assets_dir: str | None = None,
|
|
138
|
+
document: bool | None = None,
|
|
139
|
+
style_map: dict | None = None,
|
|
140
|
+
custom_renderers: dict | None = None,
|
|
141
|
+
allow_raw_html: bool | None = None,
|
|
142
|
+
config: str | None = None,
|
|
143
|
+
) -> ConversionResult:
|
|
144
|
+
"""Converts an .odt file into HTML/CSS."""
|
|
145
|
+
|
|
146
|
+
options, disable_columns = _merge_options(
|
|
147
|
+
css=css, extract_images=extract_images, assets_dir=assets_dir,
|
|
148
|
+
document=document, style_map=style_map, custom_renderers=custom_renderers,
|
|
149
|
+
allow_raw_html=allow_raw_html, config=config,
|
|
150
|
+
)
|
|
151
|
+
package = read_odt_file(path)
|
|
152
|
+
result = _build_result(package, disable_columns=disable_columns, **options)
|
|
153
|
+
if output_dir:
|
|
154
|
+
result.write(output_dir)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def convert_bytes(
|
|
159
|
+
data: bytes,
|
|
160
|
+
*,
|
|
161
|
+
css: bool | None = None,
|
|
162
|
+
extract_images: bool | None = None,
|
|
163
|
+
assets_dir: str | None = None,
|
|
164
|
+
document: bool | None = None,
|
|
165
|
+
style_map: dict | None = None,
|
|
166
|
+
custom_renderers: dict | None = None,
|
|
167
|
+
allow_raw_html: bool | None = None,
|
|
168
|
+
config: str | None = None,
|
|
169
|
+
) -> ConversionResult:
|
|
170
|
+
"""Same as convert(), but from in-memory bytes (section 2)."""
|
|
171
|
+
options, disable_columns = _merge_options(
|
|
172
|
+
css=css, extract_images=extract_images, assets_dir=assets_dir,
|
|
173
|
+
document=document, style_map=style_map, custom_renderers=custom_renderers,
|
|
174
|
+
allow_raw_html=allow_raw_html, config=config,
|
|
175
|
+
)
|
|
176
|
+
package = read_odt_bytes(data)
|
|
177
|
+
return _build_result(package, disable_columns=disable_columns, **options)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check(path: str) -> list[Warning]:
|
|
181
|
+
"""Validates an .odt document without generating HTML."""
|
|
182
|
+
|
|
183
|
+
package = read_odt_file(path)
|
|
184
|
+
doc_model = parse_document(package)
|
|
185
|
+
return [Warning(message=w, level=WarningLevel.WARNING, source="parser")
|
|
186
|
+
for w in doc_model.warnings]
|