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/__init__.py +19 -0
- corpdoc/api.py +480 -0
- corpdoc/canvas.py +230 -0
- corpdoc/cli.py +284 -0
- corpdoc/colors.py +173 -0
- corpdoc/flowables.py +407 -0
- corpdoc/i18n.py +59 -0
- corpdoc/logo.py +89 -0
- corpdoc/parser.py +214 -0
- corpdoc/styles.py +260 -0
- corpdoc-0.4.0.dist-info/METADATA +260 -0
- corpdoc-0.4.0.dist-info/RECORD +16 -0
- corpdoc-0.4.0.dist-info/WHEEL +5 -0
- corpdoc-0.4.0.dist-info/entry_points.txt +2 -0
- corpdoc-0.4.0.dist-info/licenses/LICENSE +21 -0
- corpdoc-0.4.0.dist-info/top_level.txt +1 -0
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)
|