corpdoc 0.4.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.
corpdoc/canvas.py ADDED
@@ -0,0 +1,230 @@
1
+ """
2
+ Custom ReportLab canvas with corporate header and footer.
3
+
4
+ Header: brand tagline (with split-color styling) on left, logo on right.
5
+ Footer: company legal info with key/value brand-split coloring, page numbers.
6
+ """
7
+
8
+ from reportlab.pdfgen import canvas
9
+ from reportlab.lib.colors import HexColor
10
+ from reportlab.lib.units import cm
11
+
12
+ from corpdoc.i18n import t
13
+
14
+
15
+ def brand_split_draw(canv, x, y, text, n, accent_hex, primary_hex, font="Helvetica", size=7):
16
+ """
17
+ Draw text on canvas with "brand split" coloring:
18
+ First `n` chars of each word in accent, rest in primary.
19
+ """
20
+ cursor = x
21
+ words = text.split()
22
+ for wi, word in enumerate(words):
23
+ if wi > 0:
24
+ canv.setFont(font, size)
25
+ canv.setFillColor(HexColor(primary_hex))
26
+ canv.drawString(cursor, y, " ")
27
+ cursor += canv.stringWidth(" ", font, size)
28
+
29
+ head, tail = word[:n], word[n:]
30
+ canv.setFont(font, size)
31
+ canv.setFillColor(HexColor(accent_hex))
32
+ canv.drawString(cursor, y, head)
33
+ cursor += canv.stringWidth(head, font, size)
34
+
35
+ if tail:
36
+ canv.setFillColor(HexColor(primary_hex))
37
+ canv.drawString(cursor, y, tail)
38
+ cursor += canv.stringWidth(tail, font, size)
39
+
40
+ return cursor
41
+
42
+
43
+ def brand_split_inline(text, n, accent_hex, primary_hex):
44
+ """
45
+ Return ReportLab-compatible XML string for brand-split text
46
+ (use inside Paragraph).
47
+ """
48
+ words = text.split()
49
+ parts = []
50
+ for w in words:
51
+ if len(w) <= n:
52
+ parts.append(f'<font color="{accent_hex}">{w}</font>')
53
+ else:
54
+ parts.append(
55
+ f'<font color="{accent_hex}">{w[:n]}</font>'
56
+ f'<font color="{primary_hex}">{w[n:]}</font>'
57
+ )
58
+ return " ".join(parts)
59
+
60
+
61
+ class CorpCanvas(canvas.Canvas):
62
+ """
63
+ Canvas that draws corporate header and footer on every page except
64
+ the cover (page 1) and version history (page 2).
65
+ """
66
+
67
+ def __init__(self, *args, **kwargs):
68
+ self._corp = kwargs.pop("_corp", {})
69
+ super().__init__(*args, **kwargs)
70
+ self._page_states = []
71
+
72
+ def showPage(self):
73
+ self._page_states.append(dict(self.__dict__))
74
+ self._startPage()
75
+
76
+ def save(self):
77
+ total = len(self._page_states)
78
+ for state in self._page_states:
79
+ self.__dict__.update(state)
80
+ self._draw_header_footer(total)
81
+ canvas.Canvas.showPage(self)
82
+ canvas.Canvas.save(self)
83
+
84
+ def _draw_header_footer(self, total_pages):
85
+ pn = self._pageNumber
86
+ # Skip cover (page 1) and version history (page 2)
87
+ if pn <= 2:
88
+ return
89
+
90
+ corp = self._corp
91
+ w, h = self._pagesize
92
+ accent = corp.get("accent", "#b9885a")
93
+ primary = corp.get("primary", "#2e1e19")
94
+ lang = corp.get("lang", "en")
95
+ split_n = corp.get("brand_split", 3)
96
+
97
+ # ── HEADER ──
98
+ self._draw_header(w, h, corp, accent, primary, split_n)
99
+
100
+ # ── FOOTER ──
101
+ self._draw_footer(w, h, corp, accent, primary, split_n, pn, total_pages, lang)
102
+
103
+ def _draw_header(self, w, h, corp, accent, primary, split_n):
104
+ # Logo right-aligned
105
+ logo = corp.get("logo")
106
+ header_scale = corp.get("header_scale", 0.45)
107
+ if logo:
108
+ iw, ih = logo.width, logo.height
109
+ # Base size 170pt * scale
110
+ target_w = 170 * header_scale
111
+ sc = target_w / iw
112
+ dw, dh = iw * sc, ih * sc
113
+ try:
114
+ logo.draw(self, w - 2 * cm - dw, h - 0.6 * cm - dh, dw, dh)
115
+ except Exception:
116
+ pass
117
+
118
+ # Tagline (left)
119
+ tagline = corp.get("tagline", "")
120
+ if tagline:
121
+ # Short prefix in italic (e.g. "jes.")
122
+ prefix = corp.get("tagline_prefix", "")
123
+ x_cursor = 2 * cm
124
+ if prefix:
125
+ self.setFont("Helvetica-Oblique", 8)
126
+ self.setFillColor(HexColor(accent))
127
+ self.drawString(x_cursor, h - 1.3 * cm, prefix)
128
+ x_cursor += self.stringWidth(prefix, "Helvetica-Oblique", 8) + 8
129
+
130
+ brand_split_draw(
131
+ self, x_cursor, h - 1.3 * cm, tagline, split_n, accent, primary, "Helvetica", 7
132
+ )
133
+
134
+ # Header accent line
135
+ self.setStrokeColor(HexColor(accent))
136
+ self.setLineWidth(0.4)
137
+ self.line(2 * cm, h - 1.6 * cm, w - 2 * cm, h - 1.6 * cm)
138
+
139
+ def _draw_footer(self, w, h, corp, accent, primary, split_n, pn, total, lang):
140
+ footer_y = 2.6 * cm
141
+
142
+ # Footer accent line
143
+ self.setStrokeColor(HexColor(accent))
144
+ self.setLineWidth(1.0)
145
+ self.line(2 * cm, footer_y, w - 2 * cm, footer_y)
146
+
147
+ cx = w / 2
148
+
149
+ # Line 1: Company name (brand-split) + address
150
+ company_name = corp.get("company_legal", "")
151
+ address = corp.get("address", "")
152
+
153
+ if company_name:
154
+ line1 = company_name
155
+ if address:
156
+ line1 += f" | {address}"
157
+ total_w = self.stringWidth(line1, "Helvetica", 6.5)
158
+ start_x = cx - total_w / 2
159
+
160
+ end_x = brand_split_draw(
161
+ self,
162
+ start_x,
163
+ footer_y - 12,
164
+ company_name,
165
+ split_n,
166
+ accent,
167
+ primary,
168
+ "Helvetica",
169
+ 6.5,
170
+ )
171
+ if address:
172
+ self.setFont("Helvetica", 6.5)
173
+ self.setFillColor(HexColor(primary))
174
+ self.drawString(end_x, footer_y - 12, f" | {address}")
175
+
176
+ # Additional fields (key/value pairs, grouped per line)
177
+ fields = corp.get("fields", [])
178
+ field_lines = self._group_fields(fields)
179
+
180
+ for li, fline in enumerate(field_lines):
181
+ y_pos = footer_y - 23 - (li * 11)
182
+ full_text = " | ".join(f"{f['label']}: {f['value']}" for f in fline)
183
+ total_w = self.stringWidth(full_text, "Helvetica", 6.5)
184
+ cur_x = cx - total_w / 2
185
+
186
+ for fi, fld in enumerate(fline):
187
+ if fi > 0:
188
+ self.setFont("Helvetica", 6.5)
189
+ self.setFillColor(HexColor("#999999"))
190
+ sep = " | "
191
+ self.drawString(cur_x, y_pos, sep)
192
+ cur_x += self.stringWidth(sep, "Helvetica", 6.5)
193
+
194
+ # Label in accent
195
+ label = fld["label"] + ": "
196
+ self.setFont("Helvetica", 6.5)
197
+ self.setFillColor(HexColor(accent))
198
+ self.drawString(cur_x, y_pos, label)
199
+ cur_x += self.stringWidth(label, "Helvetica", 6.5)
200
+
201
+ # Value in primary
202
+ val = fld["value"]
203
+ self.setFillColor(HexColor(primary))
204
+ self.drawString(cur_x, y_pos, val)
205
+ cur_x += self.stringWidth(val, "Helvetica", 6.5)
206
+
207
+ # Page number (language-aware, right-aligned)
208
+ footer_bottom = footer_y - 23 - (len(field_lines) * 11)
209
+ pg = t(lang, "page")
210
+ of = t(lang, "of")
211
+
212
+ self.setFont("Helvetica", 7)
213
+ self.setFillColor(HexColor("#aaaaaa"))
214
+ # Page numbering accounts for cover + version page
215
+ self.drawRightString(w - 2 * cm, footer_bottom, f"{pg} {pn - 2} {of} {total - 2}")
216
+
217
+ @staticmethod
218
+ def _group_fields(fields):
219
+ """Group fields into lines of approx 90 chars max."""
220
+ lines = []
221
+ current = []
222
+ for fld in fields:
223
+ current.append(fld)
224
+ combined = " | ".join(f"{f['label']}: {f['value']}" for f in current)
225
+ if len(combined) > 90 or len(current) >= 3:
226
+ lines.append(current)
227
+ current = []
228
+ if current:
229
+ lines.append(current)
230
+ return lines
corpdoc/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """
2
+ Command-line interface for CorpDoc.
3
+
4
+ Commands:
5
+ corpdoc init Create a new corpdoc.yml from a logo
6
+ corpdoc render Generate PDF from markdown + config
7
+ corpdoc colors Print the palette extracted from a logo
8
+ corpdoc new Scaffold a new markdown document
9
+ """
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from corpdoc import __version__
17
+ from corpdoc.api import CorpDoc
18
+ from corpdoc.colors import extract_colors_from_svg, assign_roles
19
+
20
+
21
+ # Reconfigure stdout/stderr to UTF-8 so unicode glyphs render on Windows consoles
22
+ # that default to cp1252. Python 3.7+ exposes reconfigure(); guard for safety.
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ if hasattr(_stream, "reconfigure"):
25
+ try:
26
+ _stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (AttributeError, ValueError):
28
+ pass
29
+
30
+
31
+ def cmd_init(args):
32
+ """Create a new corpdoc.yml from a logo."""
33
+ if not os.path.exists(args.logo):
34
+ sys.exit(f"Error: logo file not found: {args.logo}")
35
+
36
+ logo_path = Path(args.logo)
37
+ suffix = logo_path.suffix.lower()
38
+
39
+ # Extract colors for preview (SVG only — PNG palette extraction is out of scope)
40
+ if suffix == ".svg":
41
+ colors = extract_colors_from_svg(args.logo)
42
+ palette = assign_roles(colors)
43
+ print(f"Colors extracted from {args.logo}:")
44
+ print(f" Primary: {palette['primary']}")
45
+ print(f" Secondary: {palette['secondary']}")
46
+ print(f" Accent: {palette['accent']}")
47
+
48
+ # Build logo section of the config depending on what the user supplied.
49
+ # CorpDoc accepts SVG only, PNG only, or both — whichever is available
50
+ # will be used at render time (PNG wins when both are present).
51
+ if suffix == ".svg":
52
+ sibling_png = logo_path.with_suffix(".png")
53
+ png_value = str(sibling_png) if sibling_png.exists() else ""
54
+ svg_value = args.logo
55
+ elif suffix == ".png":
56
+ sibling_svg = logo_path.with_suffix(".svg")
57
+ svg_value = str(sibling_svg) if sibling_svg.exists() else ""
58
+ png_value = args.logo
59
+ else:
60
+ sys.exit(f'Error: logo must be .svg or .png (got "{suffix}")')
61
+
62
+ # Build a sensible default tagline using the short name
63
+ short = args.name.split()[0] if args.name else "your-company"
64
+ default_tagline = args.tagline or f"{short.lower()} | engineering | solutions"
65
+
66
+ config = f"""# CorpDoc Configuration
67
+ # Edit the values below to match your company identity.
68
+
69
+ company:
70
+ legal_name: "{args.name}"
71
+ short_name: "{short}"
72
+ tagline: "{default_tagline}"
73
+ # Number of leading chars of each word colored in accent (brand-split effect)
74
+ brand_split: 3
75
+
76
+ logo:
77
+ # CorpDoc accepts SVG, PNG, or both. SVG enables automatic color extraction;
78
+ # PNG is used for embedding when present. Leave a field empty to skip it.
79
+ svg: "{svg_value}"
80
+ png: "{png_value}"
81
+ cover_scale: 1.00 # size of logo on cover page (1.0 = default)
82
+ header_scale: 0.45 # size of logo in header (fraction of cover size)
83
+
84
+ cover:
85
+ # Choose one of: classic | minimal | bold-band | split
86
+ # classic — logo top, accent band bottom with title in white
87
+ # minimal — small top-left logo, large centered title on white
88
+ # bold-band — full-page primary color, white logo + title stacked center
89
+ # split — top half primary with logo, bottom half white with title
90
+ style: "classic"
91
+
92
+ footer:
93
+ address: ""
94
+ fields:
95
+ - label: "Director"
96
+ value: ""
97
+ - label: "E-Mail"
98
+ value: ""
99
+ - label: "Internet"
100
+ value: ""
101
+
102
+ defaults:
103
+ language: "auto" # auto|en|es|de|fr
104
+ version: "1.0"
105
+ author: ""
106
+
107
+ tables:
108
+ # Tables with this many columns (or more) are auto-rotated to a landscape
109
+ # page so the content stays readable. Raise this value to disable.
110
+ landscape_threshold: 8
111
+ """
112
+
113
+ out_path = Path(args.output)
114
+ out_path.write_text(config, encoding="utf-8")
115
+ print(f"\n✓ Configuration written to: {out_path}")
116
+
117
+ print("\nNext steps:")
118
+ print(f" 1. Edit {out_path} to fill in your company details")
119
+ print(f" 2. Run: corpdoc render your-document.md --config {out_path}")
120
+
121
+
122
+ def cmd_render(args):
123
+ """Generate PDF from markdown."""
124
+ if not os.path.exists(args.md):
125
+ sys.exit(f"Error: markdown file not found: {args.md}")
126
+ if not os.path.exists(args.config):
127
+ sys.exit(f"Error: config file not found: {args.config}")
128
+
129
+ print(f"Rendering {args.md}...")
130
+ doc = CorpDoc(config=args.config)
131
+ print(f" Primary: {doc.colors['primary']}")
132
+ print(f" Secondary: {doc.colors['secondary']}")
133
+
134
+ output = args.output or Path(args.md).with_suffix(".pdf")
135
+ result = doc.render(args.md, output=str(output))
136
+ print(f"\n✓ PDF generated: {result}")
137
+
138
+
139
+ def cmd_colors(args):
140
+ """Print the palette extracted from a logo."""
141
+ if not os.path.exists(args.logo):
142
+ sys.exit(f"Error: logo file not found: {args.logo}")
143
+
144
+ raw = extract_colors_from_svg(args.logo)
145
+ palette = assign_roles(raw)
146
+
147
+ print(f"\nColors extracted from: {args.logo}")
148
+ print("─" * 60)
149
+ print(f"Raw colors found: {raw}")
150
+ print("─" * 60)
151
+ print("Role assignments:")
152
+ for role in ["primary", "secondary", "accent", "light", "lighter"]:
153
+ color = palette[role]
154
+ # ANSI color block
155
+ r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
156
+ block = f"\033[48;2;{r};{g};{b}m \033[0m"
157
+ print(f" {block} {color} ({role})")
158
+
159
+
160
+ def cmd_new(args):
161
+ """Scaffold a new markdown document."""
162
+ templates = {
163
+ "generic": """# {title}
164
+ ## {subtitle}
165
+
166
+ Write your content here.
167
+
168
+ ## Section 1
169
+
170
+ Your content.
171
+
172
+ ## Section 2
173
+
174
+ More content.
175
+ """,
176
+ "offer": """# {title}
177
+ ## {subtitle}
178
+
179
+ Brief executive summary of the offer.
180
+
181
+ ## 1. Scope
182
+
183
+ Describe the scope of work.
184
+
185
+ ## 2. Approach
186
+
187
+ Describe the proposed approach.
188
+
189
+ ## 3. Deliverables
190
+
191
+ | Item | Description | Quantity |
192
+ |------|-------------|----------|
193
+ | A | ... | 1 |
194
+
195
+ ## 4. Timeline
196
+
197
+ Describe the timeline.
198
+
199
+ ## 5. Budget
200
+
201
+ Describe the budget and payment terms.
202
+ """,
203
+ "report": """# {title}
204
+ ## {subtitle}
205
+
206
+ ## 1. Executive Summary
207
+
208
+ Brief summary of findings.
209
+
210
+ ## 2. Methodology
211
+
212
+ Describe the approach.
213
+
214
+ ## 3. Results
215
+
216
+ Present the data.
217
+
218
+ ## 4. Conclusions
219
+
220
+ Summarize conclusions.
221
+
222
+ ## 5. Recommendations
223
+
224
+ List recommendations.
225
+ """,
226
+ }
227
+
228
+ template = templates.get(args.type, templates["generic"])
229
+ content = template.format(
230
+ title=args.title or "Document Title",
231
+ subtitle=args.subtitle or "Subtitle or client name",
232
+ )
233
+
234
+ out_path = Path(args.output)
235
+ out_path.write_text(content, encoding="utf-8")
236
+ print(f"✓ New {args.type} document created: {out_path}")
237
+ print(f" Edit the file and run: corpdoc render {out_path} --config corpdoc.yml")
238
+
239
+
240
+ def main():
241
+ parser = argparse.ArgumentParser(
242
+ prog="corpdoc", description="CorpDoc — Professional Corporate PDF Generator"
243
+ )
244
+ parser.add_argument("-V", "--version", action="version", version=f"corpdoc {__version__}")
245
+ sub = parser.add_subparsers(dest="command", required=True)
246
+
247
+ # init
248
+ p_init = sub.add_parser("init", help="Create a new corpdoc.yml from a logo")
249
+ p_init.add_argument(
250
+ "--logo",
251
+ required=True,
252
+ help="Path to your logo (.svg or .png — SVG preferred for color extraction)",
253
+ )
254
+ p_init.add_argument("--name", required=True, help="Company legal name")
255
+ p_init.add_argument("--tagline", default="", help="Header tagline (optional)")
256
+ p_init.add_argument("--output", default="corpdoc.yml", help="Output config path")
257
+ p_init.set_defaults(func=cmd_init)
258
+
259
+ # render
260
+ p_render = sub.add_parser("render", help="Generate PDF from markdown + config")
261
+ p_render.add_argument("md", help="Markdown file")
262
+ p_render.add_argument("--config", default="corpdoc.yml", help="Config YAML path")
263
+ p_render.add_argument("--output", default="", help="Output PDF path")
264
+ p_render.set_defaults(func=cmd_render)
265
+
266
+ # colors
267
+ p_colors = sub.add_parser("colors", help="Show palette extracted from a logo")
268
+ p_colors.add_argument("logo", help="SVG logo file")
269
+ p_colors.set_defaults(func=cmd_colors)
270
+
271
+ # new
272
+ p_new = sub.add_parser("new", help="Create a new document skeleton")
273
+ p_new.add_argument("type", choices=["generic", "offer", "report"], help="Document type")
274
+ p_new.add_argument("--title", default="", help="Document title")
275
+ p_new.add_argument("--subtitle", default="", help="Document subtitle")
276
+ p_new.add_argument("--output", default="document.md", help="Output .md path")
277
+ p_new.set_defaults(func=cmd_new)
278
+
279
+ args = parser.parse_args()
280
+ args.func(args)
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
corpdoc/colors.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ Color extraction and role assignment from SVG logos.
3
+
4
+ Extracts hex colors from SVG files by parsing XML attributes (fill, stroke)
5
+ and inline styles. Then assigns semantic roles (primary, secondary, accent)
6
+ based on luminance and saturation.
7
+ """
8
+
9
+ import re
10
+ from xml.etree import ElementTree as ET
11
+ from collections import Counter
12
+
13
+
14
+ HEX_PATTERN = re.compile(r"#([0-9a-fA-F]{3,8})\b")
15
+ RGB_PATTERN = re.compile(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)")
16
+
17
+
18
+ def extract_colors_from_svg(svg_path):
19
+ """
20
+ Extract all hex colors used in an SVG file, ordered by frequency.
21
+
22
+ Parses XML for fill, stroke, stop-color, and style attributes. Also
23
+ scans embedded <style> blocks for colors.
24
+
25
+ Args:
26
+ svg_path: Path to .svg file
27
+
28
+ Returns:
29
+ List of hex color strings (e.g. ['#1a2b3c', '#b9885a'])
30
+ ordered by frequency of appearance.
31
+ """
32
+ tree = ET.parse(svg_path)
33
+ root = tree.getroot()
34
+ colors = Counter()
35
+
36
+ for elem in root.iter():
37
+ # Direct color attributes
38
+ for attr in ("fill", "stroke", "stop-color", "color", "flood-color"):
39
+ val = elem.get(attr, "")
40
+ if val and val not in ("none", "transparent", "currentColor"):
41
+ for m in HEX_PATTERN.finditer(val):
42
+ colors[_normalize_hex("#" + m.group(1))] += 1
43
+ for m in RGB_PATTERN.finditer(val):
44
+ r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
45
+ colors[f"#{r:02x}{g:02x}{b:02x}"] += 1
46
+
47
+ # Inline style attribute
48
+ style = elem.get("style", "")
49
+ if style:
50
+ for m in HEX_PATTERN.finditer(style):
51
+ colors[_normalize_hex("#" + m.group(1))] += 1
52
+
53
+ # Embedded <style> blocks
54
+ for elem in root.iter():
55
+ tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
56
+ if tag == "style" and elem.text:
57
+ for m in HEX_PATTERN.finditer(elem.text):
58
+ colors[_normalize_hex("#" + m.group(1))] += 1
59
+
60
+ return [c for c, _ in sorted(colors.items(), key=lambda x: -x[1])]
61
+
62
+
63
+ def _normalize_hex(color):
64
+ """Convert #RGB to #RRGGBB, lowercase."""
65
+ color = color.lower()
66
+ if len(color) == 4:
67
+ return f"#{color[1] * 2}{color[2] * 2}{color[3] * 2}"
68
+ return color
69
+
70
+
71
+ def luminance(hex_color):
72
+ """Perceived brightness (0-255) using ITU-R BT.601 weighting."""
73
+ h = hex_color.lstrip("#")
74
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
75
+ return 0.299 * r + 0.587 * g + 0.114 * b
76
+
77
+
78
+ def saturation(hex_color):
79
+ """HSV saturation (0.0 - 1.0)."""
80
+ h = hex_color.lstrip("#")
81
+ r = int(h[0:2], 16) / 255
82
+ g = int(h[2:4], 16) / 255
83
+ b = int(h[4:6], 16) / 255
84
+ mx, mn = max(r, g, b), min(r, g, b)
85
+ return (mx - mn) / mx if mx > 0 else 0
86
+
87
+
88
+ def lighten(hex_color, factor):
89
+ """Lighten a color by mixing with white. factor: 0.0-1.0."""
90
+ h = hex_color.lstrip("#")
91
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
92
+ r = int(r + (255 - r) * factor)
93
+ g = int(g + (255 - g) * factor)
94
+ b = int(b + (255 - b) * factor)
95
+ return f"#{r:02x}{g:02x}{b:02x}"
96
+
97
+
98
+ def darken(hex_color, factor):
99
+ """Darken a color by mixing with black. factor: 0.0-1.0."""
100
+ h = hex_color.lstrip("#")
101
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
102
+ r = int(r * (1 - factor))
103
+ g = int(g * (1 - factor))
104
+ b = int(b * (1 - factor))
105
+ return f"#{r:02x}{g:02x}{b:02x}"
106
+
107
+
108
+ def assign_roles(colors):
109
+ """
110
+ Assign semantic roles to a list of extracted colors.
111
+
112
+ Strategy:
113
+ - primary: darkest color (best for body text, headings)
114
+ - secondary: most saturated color distinct from primary (headings, text highlights)
115
+ - accent: next most saturated color distinct from primary and secondary
116
+ (lines, cover band). Falls back to secondary if the palette
117
+ only has two usable colors.
118
+ - highlight: a 4th tone — next darkest color distinct from the first three
119
+ (H3 headings, subtle dividers). Falls back to a darkened
120
+ primary when the palette doesn't offer a distinct 4th color.
121
+ - light: primary lightened 88% (for subtle backgrounds)
122
+ - lighter: secondary lightened 92% (for zebra rows)
123
+
124
+ Args:
125
+ colors: List of hex color strings.
126
+
127
+ Returns:
128
+ Dict with keys: primary, secondary, accent, highlight, light, lighter, text.
129
+ """
130
+ # Filter out near-white and near-black
131
+ filtered = [c for c in colors if 20 < luminance(c) < 240]
132
+ if not filtered:
133
+ filtered = colors if colors else ["#333333"]
134
+
135
+ by_lum = sorted(filtered, key=luminance)
136
+ by_sat = sorted(filtered, key=saturation, reverse=True)
137
+
138
+ primary = by_lum[0]
139
+
140
+ # Secondary: most saturated color that is not primary
141
+ secondary = next(
142
+ (c for c in by_sat if c != primary),
143
+ by_lum[1] if len(by_lum) > 1 else lighten(primary, 0.3),
144
+ )
145
+
146
+ # Accent: next most saturated distinct from both
147
+ accent = next(
148
+ (c for c in by_sat if c != primary and c != secondary),
149
+ secondary,
150
+ )
151
+
152
+ # Highlight: next darkest color distinct from the first three. Used for
153
+ # a 4th tone (H3 headings, secondary dividers). Darkened primary fallback.
154
+ highlight = next(
155
+ (c for c in by_lum if c not in (primary, secondary, accent)),
156
+ darken(primary, 0.25),
157
+ )
158
+
159
+ return {
160
+ "primary": primary,
161
+ "secondary": secondary,
162
+ "accent": accent,
163
+ "highlight": highlight,
164
+ "light": lighten(primary, 0.88),
165
+ "lighter": lighten(secondary, 0.92),
166
+ "text": "#333333",
167
+ }
168
+
169
+
170
+ def extract_and_assign(svg_path):
171
+ """Convenience: extract colors and assign roles in one call."""
172
+ colors = extract_colors_from_svg(svg_path)
173
+ return assign_roles(colors)