feat: build Learning Planner evidence project
This commit is contained in:
304
scripts/build_evidence_docs.py
Normal file
304
scripts/build_evidence_docs.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.section import WD_SECTION
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Inches, Pt, RGBColor
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "evidence-documents"
|
||||
SHOTS = ROOT / "docs" / "evidence" / "screenshots"
|
||||
|
||||
INK = RGBColor(33, 32, 55)
|
||||
BLUE = RGBColor(46, 116, 181)
|
||||
DARK_BLUE = RGBColor(31, 77, 120)
|
||||
MUTED = RGBColor(103, 101, 119)
|
||||
PURPLE = RGBColor(98, 87, 217)
|
||||
GREEN = RGBColor(11, 143, 116)
|
||||
PALE = "F2F4F7"
|
||||
|
||||
|
||||
def set_font(run, size: float, color=INK, bold: bool | None = None, italic: bool | None = None):
|
||||
run.font.name = "Calibri"
|
||||
run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), "Calibri")
|
||||
run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), "Calibri")
|
||||
run.font.size = Pt(size)
|
||||
run.font.color.rgb = color
|
||||
if bold is not None:
|
||||
run.bold = bold
|
||||
if italic is not None:
|
||||
run.italic = italic
|
||||
|
||||
|
||||
def shade_paragraph(paragraph, fill: str, border: str = "D9DCE4"):
|
||||
p_pr = paragraph._p.get_or_add_pPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:fill"), fill)
|
||||
p_pr.append(shd)
|
||||
borders = OxmlElement("w:pBdr")
|
||||
for side in ("top", "left", "bottom", "right"):
|
||||
edge = OxmlElement(f"w:{side}")
|
||||
edge.set(qn("w:val"), "single")
|
||||
edge.set(qn("w:sz"), "6")
|
||||
edge.set(qn("w:color"), border)
|
||||
edge.set(qn("w:space"), "6")
|
||||
borders.append(edge)
|
||||
p_pr.append(borders)
|
||||
|
||||
|
||||
def add_page_number(paragraph):
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
run = paragraph.add_run("Learning Planner · Competency evidence · ")
|
||||
set_font(run, 8.5, MUTED)
|
||||
fld = OxmlElement("w:fldSimple")
|
||||
fld.set(qn("w:instr"), "PAGE")
|
||||
paragraph._p.append(fld)
|
||||
|
||||
|
||||
def configure_document(doc: Document, title: str, competency: str):
|
||||
section = doc.sections[0]
|
||||
section.top_margin = Inches(0.82)
|
||||
section.bottom_margin = Inches(0.78)
|
||||
section.left_margin = Inches(1)
|
||||
section.right_margin = Inches(1)
|
||||
section.header_distance = Inches(0.42)
|
||||
section.footer_distance = Inches(0.42)
|
||||
|
||||
normal = doc.styles["Normal"]
|
||||
normal.font.name = "Calibri"
|
||||
normal._element.rPr.rFonts.set(qn("w:ascii"), "Calibri")
|
||||
normal._element.rPr.rFonts.set(qn("w:hAnsi"), "Calibri")
|
||||
normal.font.size = Pt(11)
|
||||
normal.font.color.rgb = INK
|
||||
normal.paragraph_format.space_before = Pt(0)
|
||||
normal.paragraph_format.space_after = Pt(6)
|
||||
normal.paragraph_format.line_spacing = 1.1
|
||||
|
||||
for style_name, size, color, before, after in (
|
||||
("Heading 1", 16, BLUE, 16, 8),
|
||||
("Heading 2", 13, BLUE, 12, 6),
|
||||
("Heading 3", 12, DARK_BLUE, 8, 4),
|
||||
):
|
||||
style = doc.styles[style_name]
|
||||
style.font.name = "Calibri"
|
||||
style._element.rPr.rFonts.set(qn("w:ascii"), "Calibri")
|
||||
style._element.rPr.rFonts.set(qn("w:hAnsi"), "Calibri")
|
||||
style.font.size = Pt(size)
|
||||
style.font.bold = True
|
||||
style.font.color.rgb = color
|
||||
style.paragraph_format.space_before = Pt(before)
|
||||
style.paragraph_format.space_after = Pt(after)
|
||||
style.paragraph_format.keep_with_next = True
|
||||
|
||||
header = section.header.paragraphs[0]
|
||||
header.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
header_run = header.add_run(f"ENGINEERING COMPETENCY · {competency.upper()}")
|
||||
set_font(header_run, 8.5, MUTED, bold=True)
|
||||
add_page_number(section.footer.paragraphs[0])
|
||||
|
||||
doc.core_properties.title = title
|
||||
doc.core_properties.subject = competency
|
||||
doc.core_properties.author = "Learning Planner project"
|
||||
|
||||
|
||||
def add_masthead(doc: Document, title: str, subtitle: str, status: str):
|
||||
kicker = doc.add_paragraph()
|
||||
kicker.paragraph_format.space_after = Pt(2)
|
||||
run = kicker.add_run("COMPETENCY EVIDENCE BRIEF")
|
||||
set_font(run, 9, PURPLE, bold=True)
|
||||
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(4)
|
||||
run = p.add_run(title)
|
||||
set_font(run, 23, INK, bold=True)
|
||||
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(12)
|
||||
run = p.add_run(subtitle)
|
||||
set_font(run, 11.5, MUTED)
|
||||
|
||||
meta = doc.add_paragraph()
|
||||
meta.paragraph_format.space_before = Pt(0)
|
||||
meta.paragraph_format.space_after = Pt(12)
|
||||
meta.add_run("Project: ").bold = True
|
||||
meta.add_run("Learning Planner ")
|
||||
meta.add_run("Date: ").bold = True
|
||||
meta.add_run("14 August 2026 ")
|
||||
meta.add_run("Status: ").bold = True
|
||||
status_run = meta.add_run(status)
|
||||
status_run.font.color.rgb = GREEN if status == "Implemented" else RGBColor(151, 99, 12)
|
||||
|
||||
|
||||
def add_section(doc: Document, heading: str, paragraphs: Iterable[tuple[str | None, str]]):
|
||||
doc.add_heading(heading, level=2)
|
||||
for label, text in paragraphs:
|
||||
p = doc.add_paragraph()
|
||||
if label:
|
||||
lead = p.add_run(f"{label}: ")
|
||||
lead.bold = True
|
||||
lead.font.color.rgb = DARK_BLUE
|
||||
p.add_run(text)
|
||||
|
||||
|
||||
def add_callout(doc: Document, label: str, text: str, fill: str = PALE):
|
||||
p = doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Inches(0.12)
|
||||
p.paragraph_format.right_indent = Inches(0.12)
|
||||
p.paragraph_format.space_before = Pt(5)
|
||||
p.paragraph_format.space_after = Pt(9)
|
||||
shade_paragraph(p, fill)
|
||||
run = p.add_run(f"{label} ")
|
||||
set_font(run, 10.5, DARK_BLUE, bold=True)
|
||||
run = p.add_run(text)
|
||||
set_font(run, 10.5, INK)
|
||||
|
||||
|
||||
def add_appendix_page(doc: Document, evidence_id: str, caption: str, image_name: str):
|
||||
doc.add_page_break()
|
||||
kicker = doc.add_paragraph()
|
||||
kicker.paragraph_format.space_after = Pt(2)
|
||||
run = kicker.add_run("SCREENSHOT APPENDIX · EXCLUDED FROM TWO-PAGE NARRATIVE LIMIT")
|
||||
set_font(run, 8.5, PURPLE, bold=True)
|
||||
title = doc.add_paragraph()
|
||||
title.paragraph_format.space_after = Pt(5)
|
||||
run = title.add_run(f"{evidence_id} {caption}")
|
||||
set_font(run, 15, INK, bold=True)
|
||||
image = SHOTS / image_name
|
||||
if image.exists():
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
p.paragraph_format.space_after = Pt(6)
|
||||
shape = p.add_run().add_picture(str(image), width=Inches(6.25))
|
||||
shape._inline.docPr.set("descr", caption)
|
||||
shape._inline.docPr.set("title", evidence_id)
|
||||
note = doc.add_paragraph()
|
||||
note.paragraph_format.space_before = Pt(0)
|
||||
note.paragraph_format.space_after = Pt(0)
|
||||
note.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = note.add_run(f"Figure {evidence_id}. {caption}. Generated from non-sensitive sample data.")
|
||||
set_font(run, 9, MUTED, italic=True)
|
||||
|
||||
|
||||
def build_project_management():
|
||||
doc = Document()
|
||||
configure_document(doc, "Managing a Personal Engineering Project", "Create and manage personal projects")
|
||||
add_masthead(doc, "Managing a Personal Engineering Project", "Planning, delivering and evidencing the Learning Planner web application.", "Implemented")
|
||||
add_section(doc, "Objective", [(None, "Plan and deliver a realistic solo web application while making scope, dependencies, checkpoints, decisions and progress auditable to an engineering assessor.")])
|
||||
add_section(doc, "Approach", [
|
||||
("Scope", "I selected a local-first learning planner: substantial enough for typed state, CRUD, validation, responsive design and test automation, but bounded by excluding accounts, cloud sync and a backend."),
|
||||
("Control", "I converted the work into P01–P14, linked dependencies, identified the critical path and defined six milestone exit checks. A versioned board snapshot, roadmap, test log and decision log preserve the evidence."),
|
||||
])
|
||||
add_section(doc, "Work completed", [
|
||||
("Foundation", "Created the React/TypeScript/Vite project, quality scripts, stable domain interfaces, a versioned storage adapter and realistic sample data."),
|
||||
("Product", "Delivered goal/task CRUD, dashboard summaries, search/filter/sort, validation, confirmation, JSON backup/recovery and accessible responsive interactions."),
|
||||
("Governance", "Recorded acceptance criteria, dependency mapping, milestone status, risks and the RWD-01 decision. Manual Edge/device items remain visibly pending rather than being reported as complete."),
|
||||
])
|
||||
add_callout(doc, "Critical path", "P01 → P02/P03 → P04 → P05 → P06 → P07 → P10 → P11 → P12 → P13 → P14. Search/filter and backup proceeded as parallel branches before accessibility and final testing.")
|
||||
|
||||
doc.add_page_break()
|
||||
add_section(doc, "Testing performed", [
|
||||
("Quality gate", "Lint, nine unit/component tests, TypeScript compilation and the production build passed."),
|
||||
("Browser workflows", "Playwright verified persisted goal creation, task search/status changes and horizontal-overflow protection across Google Chrome, Chromium, Firefox and all required automated viewports."),
|
||||
("Checkpoint review", "I compared implementation against each backlog acceptance criterion and retained named test/evidence IDs. This exposed one visual defect at 1680×1050, which was fixed and retested."),
|
||||
])
|
||||
add_section(doc, "Outcome", [(None, "The project is runnable from a clean clone, the automated delivery scope is complete and the repository contains a portable audit trail. The assessor-ready release remains gated by genuine Edge and two-physical-device sessions, for which a signed checklist and screenshot naming protocol are supplied.")])
|
||||
add_section(doc, "Lessons learned", [
|
||||
("Make dependencies explicit", "The data model and responsive shell could progress in parallel, but CRUD depended on both; documenting that convergence kept sequencing clear."),
|
||||
("Treat evidence as a deliverable", "Test IDs, seeded data and screenshot names must be designed early. Retrofitting them would have produced weaker, less repeatable evidence."),
|
||||
("Report status truthfully", "A viewport emulator is not a physical device and Chromium is not Edge. Visible pending checkpoints are stronger engineering evidence than unsupported completion claims."),
|
||||
])
|
||||
add_callout(doc, "Evidence map", "PM-01 overview and acceptance criteria · PM-02 backlog/dependencies/roadmap · PM-03 test and checkpoint logs · Appendix figures show the delivered application and validated goal workflow.", "EEF0FF")
|
||||
add_appendix_page(doc, "PM-04", "Evidence-ready application at 1920×1080", "responsive-1920x1080-dashboard.png")
|
||||
add_appendix_page(doc, "PM-05", "Goal creation workflow at the 1680×1050 checkpoint", "responsive-1680x1050-goal-dialog.png")
|
||||
return doc
|
||||
|
||||
|
||||
def build_devices():
|
||||
doc = Document()
|
||||
configure_document(doc, "Testing Across Multiple Devices", "Test against multiple devices")
|
||||
add_masthead(doc, "Testing Across Multiple Devices", "Responsive verification at three desktop resolutions with a reproducible physical-device protocol.", "Manual completion pending")
|
||||
add_section(doc, "Objective", [(None, "Demonstrate that Learning Planner remains readable, operable and visually coherent at 1920×1080, 1680×1050 and 1920×1200, then repeat the principal workflow on two genuine computers.")])
|
||||
add_section(doc, "Approach", [
|
||||
("Repeatability", "Playwright set exact viewport dimensions and used the same non-sensitive dataset and core journey at every size. Each capture includes environment metadata."),
|
||||
("Physical proof", "The manual protocol records manufacturer/model, OS, native resolution, scaling, browser version, inner viewport, tester and date for two computers. These fields cannot be completed by emulation."),
|
||||
])
|
||||
add_section(doc, "Work completed", [
|
||||
("Responsive system", "Used a fixed desktop navigation rail, max-width content, Grid/Flexbox, wrapping controls, viewport-safe dialogs, visible focus, long-text protection and reduced-motion support."),
|
||||
("Breakpoint intent", "At 1920 px the summary uses one four-card row; at 1680 px it becomes a balanced 2×2 grid; additional 1200 px height exposes more content without stretching controls."),
|
||||
("Robustness", "A 768×900 check verifies the top-navigation and single-column fallback even though it is outside the formal evidence matrix."),
|
||||
])
|
||||
add_callout(doc, "Responsive elements", "Navigation, summary cards, progress and task panels, goal cards, filters, dates, long titles, validation, focus, dialogs, empty/error states and scrolling were included in the matrix.")
|
||||
|
||||
doc.add_page_break()
|
||||
add_section(doc, "Testing performed", [
|
||||
("Automated", "At all three required viewports the persisted CRUD journey, task interaction and page-level overflow assertion passed. The narrow robustness project also passed."),
|
||||
("Visual", "Full-page captures were inspected for clipping, overlap, font wrapping, spacing, card balance and modal fit. `scrollWidth` equalled `clientWidth` at 1680 after the fix."),
|
||||
("Zoom and keyboard", "The checklist covers 100%/125% zoom, Tab/Shift+Tab/Enter/Escape, focus visibility, long content and a clean application console."),
|
||||
])
|
||||
add_section(doc, "Outcome", [(None, "Automated responsive evidence passes for 1920×1080, 1680×1050 and 1920×1200. The two physical sessions are deliberately marked pending; this document is a working competency draft until their metadata and captures replace that status.")])
|
||||
add_section(doc, "Lessons learned", [
|
||||
("Functional pass is not visual quality", "At 1680×1050 the original three-column summary left one orphaned card. Automated overflow checks passed, but visual review exposed weak hierarchy."),
|
||||
("Breakpoints should protect composition", "Changing to a 2×2 arrangement below 1800 px created balance without smaller text or cramped controls."),
|
||||
("Device evidence needs context", "Resolution alone is ambiguous when OS scaling and browser chrome change the usable viewport; recording both native and inner dimensions makes results reproducible."),
|
||||
])
|
||||
add_callout(doc, "Remaining checkpoint", "Run DV-01 and DV-02 on real computers, attach the named system/application captures, update the test log and regenerate this package.", "FFF5E6")
|
||||
add_appendix_page(doc, "RWD-01", "Dashboard at 1920×1080", "responsive-1920x1080-dashboard.png")
|
||||
add_appendix_page(doc, "RWD-02", "Dashboard at 1680×1050 after correction", "responsive-1680x1050-dashboard.png")
|
||||
add_appendix_page(doc, "RWD-03", "Dashboard at 1920×1200", "responsive-1920x1200-dashboard.png")
|
||||
add_appendix_page(doc, "RWD-04A", "Reproduced 1680×1050 card-orphan defect", "responsive-defect-before-1680x1050.png")
|
||||
add_appendix_page(doc, "RWD-04B", "Corrected balanced 2×2 summary grid", "responsive-fix-after-1680x1050.png")
|
||||
return doc
|
||||
|
||||
|
||||
def build_browsers():
|
||||
doc = Document()
|
||||
configure_document(doc, "Testing Across Multiple Browsers", "Test using multiple browsers")
|
||||
add_masthead(doc, "Testing Across Multiple Browsers", "A consistent browser strategy for Google Chrome, Mozilla Firefox and Microsoft Edge.", "Edge session pending")
|
||||
add_section(doc, "Objective", [(None, "Show that the same Learning Planner workflows and responsive layouts behave consistently in Google Chrome, Microsoft Edge and Mozilla Firefox, with reproducible developer-tools evidence and compatibility remediation.")])
|
||||
add_section(doc, "Approach", [
|
||||
("Common scenario", "Each browser starts from the same seeded dataset and executes persisted goal creation, task search/status, storage reload and overflow checks. This separates browser differences from test-data differences."),
|
||||
("Coverage", "Playwright runs the installed Google Chrome channel and a Mozilla Firefox build. A Windows Edge protocol repeats the scenario and captures version, Console, Local Storage and computed Grid evidence."),
|
||||
])
|
||||
add_section(doc, "Work completed", [
|
||||
("Compatibility design", "Used semantic HTML, system fonts, standards-based Grid/Flexbox, visible focus, reduced motion, a `crypto.randomUUID` fallback and validated browser storage."),
|
||||
("Developer tools", "Console review checks runtime errors; Application/Storage verifies persistence; computed styles expose breakpoint differences; the accessibility tree verifies useful control names and structure."),
|
||||
("Regression path", "A reproduced issue must record environment and expected/actual behaviour, use the smallest standards-based fix and gain an automated or documented manual retest."),
|
||||
])
|
||||
add_callout(doc, "Compatibility concerns", "Date inputs, font metrics/wrapping, sticky positioning, focus outlines, Grid sizing, downloads/uploads, localStorage and UUID support are explicit checklist items.")
|
||||
|
||||
doc.add_page_break()
|
||||
add_section(doc, "Testing performed", [
|
||||
("Google Chrome", "The installed Chrome channel passed the core planning journey, task interaction, overflow check and evidence capture."),
|
||||
("Mozilla Firefox", "Firefox passed the same functional and overflow checks; its seeded dashboard was captured for direct comparison."),
|
||||
("Microsoft Edge", "The Windows/manual session remains pending because Edge is not installed on this Linux host. The checklist requires the stable version page, app, Console and Local Storage captures."),
|
||||
])
|
||||
add_section(doc, "Outcome", [(None, "Chrome and Firefox provide completed, matching automated evidence with no application console errors. Edge is not claimed complete: the document becomes assessor-ready only after the genuine Edge session is logged and its appendix screenshots are added.")])
|
||||
add_section(doc, "Lessons learned", [
|
||||
("Name browsers precisely", "A Chromium engine run helps compatibility, but it is not evidence of the branded Chrome or Edge products. Launching the installed Chrome channel strengthened the result."),
|
||||
("Use tools to explain differences", "Computed styles, console messages and stored data are more diagnostic than screenshots alone; together they show whether a problem is layout, runtime or persistence."),
|
||||
("Prefer progressive enhancement", "Feature detection and narrow fallbacks are safer than broad polyfills. Add a prefix/polyfill only for a reproduced support gap and retain a regression test."),
|
||||
])
|
||||
add_callout(doc, "Backwards-compatibility response", "Confirm the support baseline, reproduce in the affected browser, prefer standards and feature detection, add Autoprefixer or a narrowly scoped fallback/polyfill only when needed, then rerun the full scenario.", "EEF0FF")
|
||||
add_appendix_page(doc, "BR-01", "Google Chrome seeded dashboard", "browser-chrome-dashboard.png")
|
||||
add_appendix_page(doc, "BR-02", "Mozilla Firefox seeded dashboard", "browser-firefox-dashboard.png")
|
||||
return doc
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
builds = {
|
||||
"01-personal-project-management.docx": build_project_management(),
|
||||
"02-multiple-device-testing.docx": build_devices(),
|
||||
"03-multiple-browser-testing.docx": build_browsers(),
|
||||
}
|
||||
for name, doc in builds.items():
|
||||
doc.save(OUT / name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
scripts/build_evidence_pdfs.py
Normal file
147
scripts/build_evidence_pdfs.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS = ROOT / "evidence-documents"
|
||||
ASSETS = ROOT / "work" / "pdf-assets"
|
||||
|
||||
|
||||
def footer(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont("Helvetica-Bold", 7.5)
|
||||
canvas.setFillColor(colors.HexColor("#6F6D82"))
|
||||
canvas.drawString(0.8 * inch, 10.55 * inch, "ENGINEERING COMPETENCY · LEARNING PLANNER")
|
||||
canvas.setFont("Helvetica", 8)
|
||||
canvas.drawRightString(7.7 * inch, 0.42 * inch, f"Learning Planner · Competency evidence · {doc.page}")
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def styles():
|
||||
base = getSampleStyleSheet()
|
||||
return {
|
||||
"body": ParagraphStyle("Body", parent=base["BodyText"], fontName="Helvetica", fontSize=9.7, leading=12.3, textColor=colors.HexColor("#212037"), spaceAfter=5),
|
||||
"h2": ParagraphStyle("H2", parent=base["Heading2"], fontName="Helvetica-Bold", fontSize=12.2, leading=14, textColor=colors.HexColor("#2E74B5"), spaceBefore=8, spaceAfter=5, keepWithNext=True),
|
||||
"title": ParagraphStyle("Title", parent=base["Title"], fontName="Helvetica-Bold", fontSize=22, leading=24, textColor=colors.HexColor("#212037"), alignment=0, spaceAfter=4),
|
||||
"appendix_title": ParagraphStyle("AppendixTitle", parent=base["Heading1"], fontName="Helvetica-Bold", fontSize=15, leading=18, textColor=colors.HexColor("#212037"), spaceAfter=6),
|
||||
"subtitle": ParagraphStyle("Subtitle", parent=base["BodyText"], fontName="Helvetica", fontSize=11.2, leading=14, textColor=colors.HexColor("#6F6D82"), spaceAfter=10),
|
||||
"kicker": ParagraphStyle("Kicker", parent=base["BodyText"], fontName="Helvetica-Bold", fontSize=8.3, leading=10, textColor=colors.HexColor("#6257D9"), spaceAfter=2),
|
||||
"caption": ParagraphStyle("Caption", parent=base["BodyText"], fontName="Helvetica-Oblique", fontSize=8.5, leading=11, textColor=colors.HexColor("#6F6D82"), alignment=TA_CENTER, spaceBefore=4),
|
||||
}
|
||||
|
||||
|
||||
def rich_text(paragraph) -> str:
|
||||
chunks = []
|
||||
for run in paragraph.runs:
|
||||
text = escape(run.text)
|
||||
if not text:
|
||||
continue
|
||||
if run.bold:
|
||||
text = f"<b>{text}</b>"
|
||||
if run.italic:
|
||||
text = f"<i>{text}</i>"
|
||||
chunks.append(text)
|
||||
return "".join(chunks) or escape(paragraph.text)
|
||||
|
||||
|
||||
def has_page_break(paragraph) -> bool:
|
||||
return bool(paragraph._p.xpath('.//w:br[@w:type="page"]'))
|
||||
|
||||
|
||||
def paragraph_image(paragraph, document, asset_dir: Path):
|
||||
blips = paragraph._p.xpath(".//a:blip")
|
||||
if not blips:
|
||||
return None
|
||||
rid = blips[0].get(qn("r:embed"))
|
||||
part = document.part.related_parts[rid]
|
||||
extension = Path(part.partname).suffix or ".png"
|
||||
path = asset_dir / f"image-{len(list(asset_dir.glob('image-*'))) + 1}{extension}"
|
||||
path.write_bytes(part.blob)
|
||||
image = Image(str(path))
|
||||
max_width, max_height = 6.25 * inch, 7.55 * inch
|
||||
scale = min(max_width / image.imageWidth, max_height / image.imageHeight)
|
||||
image.drawWidth = image.imageWidth * scale
|
||||
image.drawHeight = image.imageHeight * scale
|
||||
image.hAlign = "CENTER"
|
||||
return image
|
||||
|
||||
|
||||
def convert(docx_path: Path):
|
||||
document = Document(docx_path)
|
||||
asset_dir = ASSETS / docx_path.stem
|
||||
asset_dir.mkdir(parents=True, exist_ok=True)
|
||||
pdf_path = docx_path.with_suffix(".pdf")
|
||||
story = []
|
||||
style = styles()
|
||||
|
||||
for paragraph in document.paragraphs:
|
||||
if has_page_break(paragraph):
|
||||
story.append(PageBreak())
|
||||
continue
|
||||
|
||||
image = paragraph_image(paragraph, document, asset_dir)
|
||||
if image is not None:
|
||||
story.append(Spacer(1, 3))
|
||||
story.append(image)
|
||||
continue
|
||||
|
||||
text = paragraph.text.strip()
|
||||
if not text:
|
||||
story.append(Spacer(1, 3))
|
||||
continue
|
||||
|
||||
first_size = next((run.font.size.pt for run in paragraph.runs if run.font.size), None)
|
||||
if paragraph.style.name == "Heading 2":
|
||||
story.append(Paragraph(rich_text(paragraph), style["h2"]))
|
||||
elif first_size and first_size >= 20:
|
||||
story.append(Paragraph(rich_text(paragraph), style["title"]))
|
||||
elif text.startswith("SCREENSHOT APPENDIX"):
|
||||
story.append(Paragraph(escape(text), style["kicker"]))
|
||||
elif first_size and first_size >= 14:
|
||||
story.append(Paragraph(rich_text(paragraph), style["appendix_title"]))
|
||||
elif text == "COMPETENCY EVIDENCE BRIEF":
|
||||
story.append(Paragraph(escape(text), style["kicker"]))
|
||||
elif first_size and first_size >= 11:
|
||||
story.append(Paragraph(rich_text(paragraph), style["subtitle"]))
|
||||
elif text.startswith("Figure "):
|
||||
story.append(Paragraph(escape(text), style["caption"]))
|
||||
elif paragraph._p.xpath(".//w:shd"):
|
||||
box = Table([[Paragraph(rich_text(paragraph), style["body"])]], colWidths=[6.2 * inch])
|
||||
box.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F2F4F7")),
|
||||
("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#D9DCE4")),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 7),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]))
|
||||
story.extend([Spacer(1, 3), box, Spacer(1, 5)])
|
||||
else:
|
||||
story.append(Paragraph(rich_text(paragraph), style["body"]))
|
||||
|
||||
pdf = SimpleDocTemplate(
|
||||
str(pdf_path), pagesize=LETTER, rightMargin=0.8 * inch, leftMargin=0.8 * inch,
|
||||
topMargin=0.72 * inch, bottomMargin=0.65 * inch, title=document.core_properties.title,
|
||||
author="Learning Planner project", subject=document.core_properties.subject,
|
||||
)
|
||||
pdf.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||
|
||||
|
||||
def main():
|
||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||
for path in sorted(DOCS.glob("0*-*.docx")):
|
||||
convert(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user