fancydocx 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bilal Sharif
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: fancydocx
3
+ Version: 0.1.0
4
+ Summary: Convert fancy .docx files into a single self-contained HTML file. Pure Python, no LibreOffice.
5
+ Author-email: Bilal Sharif <bilalwork31@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/bilalwork31-cyber/docx2html
8
+ Project-URL: Repository, https://github.com/bilalwork31-cyber/docx2html
9
+ Project-URL: Issues, https://github.com/bilalwork31-cyber/docx2html/issues
10
+ Keywords: docx,html,converter,ooxml,word,resume,document,pure-python
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Text Processing :: Markup :: HTML
22
+ Classifier: Topic :: Office/Business
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: verify
27
+ Requires-Dist: Pillow>=9; extra == "verify"
28
+ Requires-Dist: numpy>=1.21; extra == "verify"
29
+ Dynamic: license-file
30
+
31
+ fancydocx
32
+ =========
33
+
34
+ Convert fancy, design-heavy Word (.docx) files into a single, self-contained
35
+ HTML file, with all styling, images and fonts inlined. It is a from-scratch
36
+ reader for the Office Open XML format, written in pure Python. No LibreOffice,
37
+ no Word, and no third-party dependencies.
38
+
39
+
40
+ Features
41
+ --------
42
+
43
+ - Tables, columns, shapes, text boxes and floating images
44
+ - Theme colours, fonts and the full paragraph/run style cascade
45
+ - Bullet and numbered lists, hyperlinks, headers and footers
46
+ - Embedded fonts are recovered and inlined automatically
47
+ - Batch conversion from a simple command-line tool
48
+
49
+
50
+ Installation
51
+ ------------
52
+
53
+ pip install fancydocx
54
+
55
+
56
+ Usage
57
+ -----
58
+
59
+ In Python:
60
+
61
+ import fancydocx
62
+
63
+ fancydocx.convert("resume.docx", "resume.html") # write a file
64
+ html = fancydocx.convert("resume.docx") # or return a string
65
+
66
+ From the command line:
67
+
68
+ fancydocx resume.docx -o resume.html
69
+ fancydocx ./documents -o ./html --workers 8 # convert a folder
70
+
71
+
72
+ Requirements
73
+ ------------
74
+
75
+ Python 3.8 or newer. The library uses only the standard library.
76
+
77
+
78
+ Notes
79
+ -----
80
+
81
+ - Text renders with the document's own fonts when they are installed on the
82
+ viewer's machine; pass --embed-fonts to inline them for portability.
83
+ - EMF/WMF vector images are shown as placeholders, as browsers cannot
84
+ display them.
85
+
86
+
87
+ License
88
+ -------
89
+
90
+ MIT. See the LICENSE file.
@@ -0,0 +1,60 @@
1
+ fancydocx
2
+ =========
3
+
4
+ Convert fancy, design-heavy Word (.docx) files into a single, self-contained
5
+ HTML file, with all styling, images and fonts inlined. It is a from-scratch
6
+ reader for the Office Open XML format, written in pure Python. No LibreOffice,
7
+ no Word, and no third-party dependencies.
8
+
9
+
10
+ Features
11
+ --------
12
+
13
+ - Tables, columns, shapes, text boxes and floating images
14
+ - Theme colours, fonts and the full paragraph/run style cascade
15
+ - Bullet and numbered lists, hyperlinks, headers and footers
16
+ - Embedded fonts are recovered and inlined automatically
17
+ - Batch conversion from a simple command-line tool
18
+
19
+
20
+ Installation
21
+ ------------
22
+
23
+ pip install fancydocx
24
+
25
+
26
+ Usage
27
+ -----
28
+
29
+ In Python:
30
+
31
+ import fancydocx
32
+
33
+ fancydocx.convert("resume.docx", "resume.html") # write a file
34
+ html = fancydocx.convert("resume.docx") # or return a string
35
+
36
+ From the command line:
37
+
38
+ fancydocx resume.docx -o resume.html
39
+ fancydocx ./documents -o ./html --workers 8 # convert a folder
40
+
41
+
42
+ Requirements
43
+ ------------
44
+
45
+ Python 3.8 or newer. The library uses only the standard library.
46
+
47
+
48
+ Notes
49
+ -----
50
+
51
+ - Text renders with the document's own fonts when they are installed on the
52
+ viewer's machine; pass --embed-fonts to inline them for portability.
53
+ - EMF/WMF vector images are shown as placeholders, as browsers cannot
54
+ display them.
55
+
56
+
57
+ License
58
+ -------
59
+
60
+ MIT. See the LICENSE file.
@@ -0,0 +1,155 @@
1
+ """
2
+ fancydocx - pure-Python DOCX -> single self-contained HTML converter.
3
+
4
+ import fancydocx
5
+ fancydocx.convert("resume.docx", "resume.html") # write a file
6
+ html = fancydocx.convert("resume.docx") # or get the HTML string
7
+
8
+ No external engines, no LibreOffice, no network. Images are inlined as data
9
+ URIs and embedded fonts are recovered as @font-face, so the output is one
10
+ portable .html file.
11
+ """
12
+ from __future__ import annotations
13
+ import html as _html
14
+ import pathlib
15
+
16
+ from .core import local
17
+ from .package import DocxPackage
18
+ from .theme import Theme
19
+ from .styles import Styles, rpr_to_css, ppr_to_css, line_height_css
20
+ from .numbering import Numbering
21
+ from .render import Converter
22
+ from .fontmetrics import embed_css_for_families
23
+
24
+ __version__ = "0.1.0"
25
+ __all__ = ["convert", "convert_docx", "convert_file", "DocxPackage", "__version__"]
26
+
27
+ BASE_CSS = """
28
+ *{box-sizing:border-box}
29
+ html,body{margin:0;padding:0}
30
+ body{background:#e9e9ee;color:#000;-webkit-print-color-adjust:exact;print-color-adjust:exact;
31
+ text-rendering:geometricPrecision}
32
+ .docx-doc{padding:24px 12px}
33
+ /* isolation:isolate makes each page its own stacking context, so that
34
+ z-index:-1 layers (header/footer art, behindDoc shapes) paint ABOVE the
35
+ page's own background but BELOW in-flow content -- exactly Word's
36
+ page-color / behind-text / text layering. Without it, negative z-index
37
+ children fall behind the page background and vanish. */
38
+ .docx-page{position:relative;background:#fff;margin:0 auto 24px;
39
+ box-shadow:0 2px 14px rgba(0,0,0,.28);overflow:hidden;isolation:isolate}
40
+ /* .docx-body is intentionally NOT positioned so absolutely-positioned floats
41
+ (anchored images/shapes) resolve against the .docx-page box = true page
42
+ coordinates, matching Word's page-relative anchoring. */
43
+ .docx-page p{margin:0}
44
+ .docx-page table{border-spacing:0;max-width:none;border-collapse:collapse}
45
+ .docx-page td,.docx-page th{vertical-align:top}
46
+ .docx-page img{max-width:none}
47
+ .docx-page a{color:inherit;text-decoration:inherit}
48
+ .leader{flex:1 1 auto;align-self:flex-end;border-bottom:1px dotted currentColor;margin:0 4px 3px}
49
+ .tab{display:inline-block;min-width:2em}
50
+ .docx-header,.docx-footer{pointer-events:none}
51
+ @media print{
52
+ html,body{background:#fff}
53
+ .docx-doc{padding:0}
54
+ .docx-page{box-shadow:none;margin:0;page-break-after:always}
55
+ @page{margin:0}
56
+ }
57
+ """
58
+
59
+
60
+ def _title(pkg, path):
61
+ core = pkg.xml("docProps/core.xml")
62
+ if core is not None:
63
+ for el in core.iter():
64
+ if local(el.tag) == "title" and el.text:
65
+ return el.text.strip()
66
+ return pathlib.Path(str(path)).stem
67
+
68
+
69
+ def _body_rule(styles, theme):
70
+ """Default inherited run/paragraph look, applied to .docx-body."""
71
+ rpr = styles.effective_rpr(None, None, {})
72
+ ppr = styles.effective_ppr(None, {})
73
+ d = rpr_to_css(rpr, theme)
74
+ out = {}
75
+ for k in ("font-family", "font-size", "color"):
76
+ if k in d:
77
+ out[k] = d[k]
78
+ # Word single spacing is font-metric based (see fontmetrics.py); the
79
+ # numeric factor keeps the geometry even under font substitution.
80
+ out["line-height"] = line_height_css(ppr.get("spacing"),
81
+ rpr.get("font"), rpr.get("sz") or 11.0)
82
+ out.setdefault("font-family", "'Calibri', 'Segoe UI', sans-serif")
83
+ out.setdefault("font-size", "11pt")
84
+ out["word-wrap"] = "break-word"
85
+ return ".docx-body{%s}" % ";".join("%s:%s" % (k, v) for k, v in out.items())
86
+
87
+
88
+ def convert_docx(path, include_headers=True, embed_fonts=False):
89
+ """
90
+ Convert a .docx file to a single self-contained HTML string.
91
+
92
+ embed_fonts: additionally inline every referenced font family found on
93
+ THIS machine as base64 @font-face. This makes the HTML render with the
94
+ exact intended glyph metrics on any viewer, at the cost of several MB
95
+ per file -- off by default for batch conversions.
96
+ """
97
+ pkg = DocxPackage(path)
98
+ try:
99
+ theme = Theme(pkg)
100
+ styles = Styles(pkg, theme)
101
+ numbering = Numbering(pkg, theme)
102
+ conv = Converter(pkg, theme, styles, numbering, include_headers=include_headers)
103
+ body = conv.render_document()
104
+ font_css, doc_families = pkg.font_face_css_and_families()
105
+ if embed_fonts:
106
+ local_css = embed_css_for_families(conv.used_fonts, already_embedded=doc_families)
107
+ if local_css:
108
+ font_css = font_css + "\n" + local_css if font_css else local_css
109
+ body_rule = _body_rule(styles, theme)
110
+ title = _title(pkg, path)
111
+ finally:
112
+ pkg.close()
113
+
114
+ return (
115
+ "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
116
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
117
+ "<title>%s</title>\n<style>\n%s\n%s\n%s\n</style>\n</head>\n<body>\n"
118
+ "<div class=\"docx-doc\">%s</div>\n</body>\n</html>\n"
119
+ % (_html.escape(title), BASE_CSS, body_rule, font_css, body)
120
+ )
121
+
122
+
123
+ def convert_file(in_path, out_path, include_headers=True, embed_fonts=False):
124
+ """Convert one .docx to one .html on disk. Returns the output path."""
125
+ result = convert_docx(in_path, include_headers=include_headers,
126
+ embed_fonts=embed_fonts)
127
+ out = pathlib.Path(out_path)
128
+ out.parent.mkdir(parents=True, exist_ok=True)
129
+ out.write_text(result, encoding="utf-8")
130
+ return str(out)
131
+
132
+
133
+ def convert(source, output=None, *, embed_fonts=False, include_headers=True):
134
+ """
135
+ One-line entry point.
136
+
137
+ import fancydocx
138
+ fancydocx.convert("resume.docx", "resume.html") # write the file, returns path
139
+ html = fancydocx.convert("resume.docx") # no output -> returns HTML str
140
+
141
+ Parameters
142
+ ----------
143
+ source : str | os.PathLike
144
+ Path to the input .docx file.
145
+ output : str | os.PathLike | None
146
+ Where to write the HTML. If None, the HTML is returned as a string.
147
+ embed_fonts : bool
148
+ Inline locally-installed referenced fonts as base64 @font-face
149
+ (exact metrics on any viewer, at the cost of file size).
150
+ include_headers : bool
151
+ Render document headers/footers (default True).
152
+ """
153
+ if output is None:
154
+ return convert_docx(source, include_headers=include_headers, embed_fonts=embed_fonts)
155
+ return convert_file(source, output, include_headers=include_headers, embed_fonts=embed_fonts)
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m fancydocx`` to run the command-line interface."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,117 @@
1
+ """
2
+ Command-line interface for fancydocx, exposed as the ``fancydocx`` command
3
+ (and ``python -m fancydocx``).
4
+
5
+ Single file:
6
+ fancydocx resume.docx -> resume.html (next to input)
7
+ fancydocx resume.docx -o out.html
8
+
9
+ Whole folder (recursive), mirroring the tree into an output dir:
10
+ fancydocx ./docs -o ./html
11
+ fancydocx ./docs -o ./html --workers 8
12
+ """
13
+ from __future__ import annotations
14
+ import argparse
15
+ import concurrent.futures as cf
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from pathlib import Path
20
+
21
+ from . import __version__, convert_file
22
+
23
+
24
+ def _iter_docx(root, pattern):
25
+ for p in sorted(Path(root).rglob(pattern)):
26
+ # Skip Word lock/temp files like ~$name.docx
27
+ if p.name.startswith("~$"):
28
+ continue
29
+ if p.is_file():
30
+ yield p
31
+
32
+
33
+ def _one(in_path, out_path, include_headers, embed_fonts=False):
34
+ t0 = time.perf_counter()
35
+ try:
36
+ convert_file(in_path, out_path, include_headers=include_headers,
37
+ embed_fonts=embed_fonts)
38
+ return (in_path, out_path, None, time.perf_counter() - t0)
39
+ except Exception as e:
40
+ return (in_path, out_path,
41
+ "".join(traceback.format_exception_only(type(e), e)).strip(),
42
+ time.perf_counter() - t0)
43
+
44
+
45
+ def main(argv=None):
46
+ ap = argparse.ArgumentParser(
47
+ prog="fancydocx",
48
+ description="Convert fancy .docx files to a single self-contained HTML file.")
49
+ ap.add_argument("input", help="A .docx file or a folder containing .docx files")
50
+ ap.add_argument("-o", "--output", help="Output .html file (single) or output folder (batch)")
51
+ ap.add_argument("--glob", default="*.docx", help="Glob for batch mode (default: *.docx)")
52
+ ap.add_argument("--workers", type=int, default=1,
53
+ help="Parallel worker processes for batch mode (default: 1)")
54
+ ap.add_argument("--no-headers", action="store_true", help="Skip header/footer rendering")
55
+ ap.add_argument("--embed-fonts", action="store_true",
56
+ help="Inline locally-installed referenced fonts as @font-face "
57
+ "(exact metrics everywhere, but several MB per file)")
58
+ ap.add_argument("--quiet", action="store_true", help="Only print a final summary")
59
+ ap.add_argument("--version", action="version", version="fancydocx %s" % __version__)
60
+ args = ap.parse_args(argv)
61
+
62
+ include_headers = not args.no_headers
63
+ inp = Path(args.input)
64
+ if not inp.exists():
65
+ ap.error("input not found: %s" % inp)
66
+
67
+ # ---- single file -------------------------------------------------
68
+ if inp.is_file():
69
+ out = Path(args.output) if args.output else inp.with_suffix(".html")
70
+ in_p, out_p, err, dt = _one(inp, out, include_headers, args.embed_fonts)
71
+ if err:
72
+ print("FAILED %s\n %s" % (in_p, err), file=sys.stderr)
73
+ return 1
74
+ print("OK %s -> %s (%.2fs)" % (in_p, out_p, dt))
75
+ return 0
76
+
77
+ # ---- batch folder ------------------------------------------------
78
+ out_dir = Path(args.output) if args.output else inp / "_html"
79
+ files = list(_iter_docx(inp, args.glob))
80
+ if not files:
81
+ print("No files matching %r under %s" % (args.glob, inp))
82
+ return 0
83
+
84
+ jobs = [(f, out_dir / f.relative_to(inp).with_suffix(".html")) for f in files]
85
+ ok = fail = 0
86
+ total = len(jobs)
87
+ started = time.perf_counter()
88
+ print("Converting %d file(s) -> %s (workers=%d)" % (total, out_dir, args.workers))
89
+
90
+ def report(res, i):
91
+ nonlocal ok, fail
92
+ in_p, out_p, err, dt = res
93
+ if err:
94
+ fail += 1
95
+ print("[%d/%d] FAILED %s\n %s" % (i, total, in_p, err), file=sys.stderr)
96
+ else:
97
+ ok += 1
98
+ if not args.quiet:
99
+ print("[%d/%d] %s -> %s (%.2fs)" % (i, total, in_p.name, out_p, dt))
100
+
101
+ if args.workers > 1:
102
+ with cf.ProcessPoolExecutor(max_workers=args.workers) as ex:
103
+ futs = {ex.submit(_one, f, o, include_headers, args.embed_fonts): idx
104
+ for idx, (f, o) in enumerate(jobs, 1)}
105
+ for fut in cf.as_completed(futs):
106
+ report(fut.result(), futs[fut])
107
+ else:
108
+ for idx, (f, o) in enumerate(jobs, 1):
109
+ report(_one(f, o, include_headers, args.embed_fonts), idx)
110
+
111
+ print("\nDone: %d ok, %d failed, %d total in %.1fs"
112
+ % (ok, fail, total, time.perf_counter() - started))
113
+ return 1 if fail else 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(main())
@@ -0,0 +1,128 @@
1
+ """
2
+ Color resolution: hex parsing, theme-color lookup, and the tint/shade
3
+ math Office applies to themed colors.
4
+
5
+ Word colors come in three flavors:
6
+ * explicit sRGB <w:color w:val="1F4E79"/>
7
+ * "auto" <w:color w:val="auto"/> (context default)
8
+ * theme reference <w:color w:themeColor="accent1" w:themeShade="BF"/>
9
+
10
+ For theme references, `themeTint`/`themeShade` are a hex fraction of 255
11
+ applied to the *luminance* of the resolved theme color (HSL space) -- this
12
+ is what Office actually does, not a naive per-channel scale, so the
13
+ accent-bar shades come out matching.
14
+ """
15
+ from __future__ import annotations
16
+ import colorsys
17
+
18
+ # Named highlight colors (<w:highlight w:val="yellow"/>).
19
+ HIGHLIGHT = {
20
+ "black": "000000", "blue": "0000FF", "cyan": "00FFFF", "darkBlue": "00008B",
21
+ "darkCyan": "008B8B", "darkGray": "A9A9A9", "darkGreen": "006400",
22
+ "darkMagenta": "8B008B", "darkRed": "8B0000", "darkYellow": "808000",
23
+ "green": "00FF00", "lightGray": "D3D3D3", "magenta": "FF00FF", "red": "FF0000",
24
+ "white": "FFFFFF", "yellow": "FFFF00",
25
+ }
26
+
27
+ # themeColor attribute value -> clrScheme key. The <w:clrSchemeMapping> in
28
+ # settings.xml can remap tx1/bg1/tx2/bg2, handled in theme.py; this is the
29
+ # default identity mapping.
30
+ THEME_ALIAS = {
31
+ "dark1": "dk1", "light1": "lt1", "dark2": "dk2", "light2": "lt2",
32
+ "text1": "dk1", "background1": "lt1", "text2": "dk2", "background2": "lt2",
33
+ "accent1": "accent1", "accent2": "accent2", "accent3": "accent3",
34
+ "accent4": "accent4", "accent5": "accent5", "accent6": "accent6",
35
+ "hyperlink": "hlink", "followedHyperlink": "folHlink",
36
+ }
37
+
38
+
39
+ def normalize_hex(val):
40
+ """Return a 6-digit uppercase hex string, or None for auto/blank/invalid."""
41
+ if not val:
42
+ return None
43
+ v = val.strip().lstrip("#")
44
+ if v.lower() == "auto":
45
+ return None
46
+ if len(v) == 3: # rare shorthand
47
+ v = "".join(c * 2 for c in v)
48
+ if len(v) != 6:
49
+ return None
50
+ try:
51
+ int(v, 16)
52
+ except ValueError:
53
+ return None
54
+ return v.upper()
55
+
56
+
57
+ def hex_to_rgb(h):
58
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
59
+
60
+
61
+ def rgb_to_hex(rgb):
62
+ return "".join("%02X" % max(0, min(255, int(round(c)))) for c in rgb)
63
+
64
+
65
+ def apply_tint_shade(hex6, tint=None, shade=None):
66
+ """
67
+ Apply themeTint / themeShade (hex byte, fraction of 255) to a base color,
68
+ operating on HSL luminance the way Office does.
69
+ """
70
+ if not hex6:
71
+ return hex6
72
+ r, g, b = (c / 255.0 for c in hex_to_rgb(hex6))
73
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
74
+ if shade is not None:
75
+ try:
76
+ f = int(shade, 16) / 255.0
77
+ l = l * f
78
+ except ValueError:
79
+ pass
80
+ if tint is not None:
81
+ try:
82
+ f = int(tint, 16) / 255.0
83
+ l = l * f + (1.0 - f)
84
+ except ValueError:
85
+ pass
86
+ l = max(0.0, min(1.0, l))
87
+ r, g, b = colorsys.hls_to_rgb(h, l, s)
88
+ return rgb_to_hex((r * 255, g * 255, b * 255))
89
+
90
+
91
+ def color_descriptor(el):
92
+ """
93
+ Build a color descriptor from any element carrying w:val / w:themeColor
94
+ (+ themeTint/themeShade). Returns None if the element is absent.
95
+ """
96
+ if el is None:
97
+ return None
98
+ from .core import qn
99
+ return {
100
+ "val": el.get(qn("w:val")),
101
+ "theme": el.get(qn("w:themeColor")),
102
+ "tint": el.get(qn("w:themeTint")),
103
+ "shade": el.get(qn("w:themeShade")),
104
+ }
105
+
106
+
107
+ def resolve(desc, theme, default=None):
108
+ """
109
+ Descriptor -> '#RRGGBB' (or `default` when it resolves to auto/none).
110
+
111
+ Precedence: when Word saves a theme-referenced color it ALSO bakes the
112
+ resolved sRGB into w:val (e.g. w:color w:val="9A92BF"
113
+ w:themeColor="accent5" w:themeTint="99"). That cached value is Word's own
114
+ integer-HSL math -- bit-exact by definition -- so prefer it and only
115
+ recompute from the theme when no explicit value exists (or it is 'auto').
116
+ """
117
+ if desc is None:
118
+ return default
119
+ hexv = normalize_hex(desc.get("val"))
120
+ if hexv:
121
+ return "#" + hexv
122
+ tname = desc.get("theme")
123
+ if tname and theme is not None:
124
+ base = theme.color(tname) or theme.color(THEME_ALIAS.get(tname, tname))
125
+ if base:
126
+ base = apply_tint_shade(base, desc.get("tint"), desc.get("shade"))
127
+ return "#" + base
128
+ return default