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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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]