Source code for jamb.publish.formats.docx

"""Word (DOCX) document output for publishing."""

import io
from typing import Any

from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt, RGBColor
from docx.text.paragraph import Paragraph

from jamb.core.models import Item, TraceabilityGraph


def _add_bookmark(paragraph: Paragraph, bookmark_name: str, bookmark_id: int) -> None:
    """Add a bookmark to a paragraph for internal linking.

    Args:
        paragraph: The docx paragraph object to add the bookmark to.
        bookmark_name: The bookmark identifier used for internal
            cross-references.
        bookmark_id: Unique numeric ID for the bookmark element.
    """
    # Create bookmark start
    bookmark_start = OxmlElement("w:bookmarkStart")
    bookmark_start.set(qn("w:id"), str(bookmark_id))
    bookmark_start.set(qn("w:name"), bookmark_name)

    # Create bookmark end
    bookmark_end = OxmlElement("w:bookmarkEnd")
    bookmark_end.set(qn("w:id"), str(bookmark_id))

    # Insert into paragraph
    paragraph._p.insert(0, bookmark_start)
    paragraph._p.append(bookmark_end)


def _add_hyperlink(paragraph: Paragraph, anchor: str, text: str) -> None:
    """Add an internal hyperlink to a paragraph.

    Args:
        paragraph: The docx paragraph object to add the hyperlink to.
        anchor: The bookmark name to link to.
        text: The display text for the hyperlink.
    """
    # Create hyperlink element
    hyperlink = OxmlElement("w:hyperlink")
    hyperlink.set(qn("w:anchor"), anchor)

    # Create run with text
    run = OxmlElement("w:r")
    run_props = OxmlElement("w:rPr")

    # Add hyperlink styling (blue, underlined)
    color = OxmlElement("w:color")
    color.set(qn("w:val"), "0000FF")
    run_props.append(color)

    underline = OxmlElement("w:u")
    underline.set(qn("w:val"), "single")
    run_props.append(underline)

    run.append(run_props)

    # Add text
    text_elem = OxmlElement("w:t")
    text_elem.text = text
    run.append(text_elem)

    hyperlink.append(run)
    paragraph._p.append(hyperlink)


def _add_page_numbers(doc: Any) -> None:
    """Add page numbers to the document footer.

    Args:
        doc: The Document object to add page numbers to.
    """
    for section in doc.sections:
        footer = section.footer
        footer.is_linked_to_previous = False
        para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
        para.alignment = WD_ALIGN_PARAGRAPH.CENTER

        # Add "Page " text
        run = para.add_run("Page ")
        run.font.size = Pt(9)

        # Add PAGE field
        fld_char_begin = OxmlElement("w:fldChar")
        fld_char_begin.set(qn("w:fldCharType"), "begin")

        instr_text = OxmlElement("w:instrText")
        instr_text.text = "PAGE"

        fld_char_end = OxmlElement("w:fldChar")
        fld_char_end.set(qn("w:fldCharType"), "end")

        run2 = para.add_run()
        run2._r.append(fld_char_begin)
        run2._r.append(instr_text)
        run2._r.append(fld_char_end)
        run2.font.size = Pt(9)

        # Add " of " text
        run3 = para.add_run(" of ")
        run3.font.size = Pt(9)

        # Add NUMPAGES field
        fld_char_begin2 = OxmlElement("w:fldChar")
        fld_char_begin2.set(qn("w:fldCharType"), "begin")

        instr_text2 = OxmlElement("w:instrText")
        instr_text2.text = "NUMPAGES"

        fld_char_end2 = OxmlElement("w:fldChar")
        fld_char_end2.set(qn("w:fldCharType"), "end")

        run4 = para.add_run()
        run4._r.append(fld_char_begin2)
        run4._r.append(instr_text2)
        run4._r.append(fld_char_end2)
        run4.font.size = Pt(9)


def _setup_styles(doc: Any) -> None:
    """Set up document styles for better formatting.

    Args:
        doc: The Document object to configure styles for.
    """
    styles = doc.styles

    # Configure Normal style
    if "Normal" in styles:
        normal = styles["Normal"]
        normal.font.name = "Calibri"
        normal.font.size = Pt(11)
        normal.paragraph_format.space_after = Pt(8)
        normal.paragraph_format.line_spacing = 1.15

    # Configure Heading 1
    if "Heading 1" in styles:
        h1 = styles["Heading 1"]
        h1.font.name = "Calibri"
        h1.font.size = Pt(16)
        h1.font.bold = True
        h1.font.color.rgb = RGBColor(0x1A, 0x36, 0x5D)
        h1.paragraph_format.space_before = Pt(18)
        h1.paragraph_format.space_after = Pt(6)

    # Configure Heading 2
    if "Heading 2" in styles:
        h2 = styles["Heading 2"]
        h2.font.name = "Calibri"
        h2.font.size = Pt(14)
        h2.font.bold = True
        h2.font.color.rgb = RGBColor(0x2C, 0x52, 0x82)
        h2.paragraph_format.space_before = Pt(14)
        h2.paragraph_format.space_after = Pt(4)

    # Create or update Item Type style for type indicators
    if "Intense Emphasis" not in styles:
        item_type_style = styles.add_style("Intense Emphasis", WD_STYLE_TYPE.CHARACTER)
    else:
        item_type_style = styles["Intense Emphasis"]
    item_type_style.font.bold = True
    item_type_style.font.size = Pt(9)
    item_type_style.font.color.rgb = RGBColor(0x4A, 0x55, 0x68)


[docs] def render_docx( items: list[Item], title: str, include_links: bool = True, document_order: list[str] | None = None, graph: TraceabilityGraph | None = None, template_path: str | None = None, ) -> bytes: """Render items as a Word document. Args: items: List of Item objects to include in the document. title: The document title. include_links: Whether to include links section for each item. document_order: Optional list of document prefixes in hierarchy order (root first, then children). If None, sorts alphabetically. graph: Optional traceability graph for child link lookup. template_path: Optional path to a DOCX template file to use as base. Returns: Bytes representing the DOCX file. """ if template_path: doc = Document(template_path) # Clear existing content from template (keep styles) for element in doc.element.body[:]: doc.element.body.remove(element) else: doc = Document() _setup_styles(doc) # Add page numbers to footer _add_page_numbers(doc) # Build set of all item UIDs for link validation all_uids = {item.uid for item in items} # Add title title_para = doc.add_heading(title, level=0) title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER # Add summary paragraph doc.add_paragraph(f"Total items: {len(items)}") doc.add_paragraph() # Build document order index for sorting if document_order: doc_order_index = {prefix: i for i, prefix in enumerate(document_order)} else: doc_order_index = {} fallback_order = len(document_order) if document_order else 0 def get_doc_order(item: Item) -> int: """Return sort index for an item based on document hierarchy order.""" return doc_order_index.get(item.document_prefix, fallback_order) # Sort items by document hierarchy, then UID sorted_items = sorted(items, key=lambda x: (get_doc_order(x), x.document_prefix, x.uid)) # Track current document for section headers current_doc = None for idx, item in enumerate(sorted_items): # Add document section header if document changed if item.document_prefix != current_doc: current_doc = item.document_prefix doc.add_heading(f"{current_doc}", level=1) # Create heading with UID and optional header item_type = item.type if item_type == "heading": heading_display = item.uid heading = doc.add_heading(heading_display, level=1) else: if item.header: heading_text = f"{item.uid}: {item.header}" else: heading_text = item.uid heading = doc.add_heading(heading_text, level=2) # Add bookmark for this item so links can reference it _add_bookmark(heading, item.uid, idx + 100) # Add item text if item.text: if item_type == "info": para = doc.add_paragraph() run = para.add_run(item.text) run.italic = True run.font.color.rgb = RGBColor(0x7F, 0x8C, 0x8D) else: doc.add_paragraph(item.text) # Add links section with hyperlinks if include_links and item.links: links_para = doc.add_paragraph() links_run = links_para.add_run("Links: ") links_run.bold = True for i, link_uid in enumerate(item.links): if i > 0: links_para.add_run(", ") # Add as hyperlink if target exists, otherwise plain text if link_uid in all_uids: _add_hyperlink(links_para, link_uid, link_uid) else: links_para.add_run(link_uid) # Add child links section (reverse links) if include_links and graph is not None: children = graph.item_children.get(item.uid, []) visible_children = [c for c in children if c in all_uids] if visible_children: child_para = doc.add_paragraph() child_run = child_para.add_run("Linked from: ") child_run.bold = True for i, child_uid in enumerate(visible_children): if i > 0: child_para.add_run(", ") _add_hyperlink(child_para, child_uid, child_uid) # Add spacing between items doc.add_paragraph() # Save to bytes output = io.BytesIO() doc.save(output) return output.getvalue()
def generate_template(output_path: str) -> None: """Generate a sample DOCX template file with jamb styles. Creates a template document that users can customize and then use with the --template option when publishing documents. Args: output_path: Path where the template file will be written. """ doc = Document() _setup_styles(doc) # Add title explaining the template title = doc.add_heading("Jamb Document Template", level=0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER intro = doc.add_paragraph() intro.add_run("How to use this template:").bold = True doc.add_paragraph( "This template defines styles that jamb uses when publishing documents. " "You can customize fonts, colors, and spacing by modifying the styles " "in this document. Then use the --template option when publishing:" ) code_para = doc.add_paragraph() code_run = code_para.add_run(" jamb publish SRS output.docx --template this-file.docx") code_run.italic = True code_run.font.size = Pt(10) doc.add_paragraph() # Section showing Heading 1 style (used for document sections and heading items) doc.add_heading("Document Section (Heading 1 Style)", level=1) doc.add_paragraph( "This style is used for document section headers (e.g., 'SRS', 'UN') " "and for items with type='heading'. Customize this style to change " "the appearance of major sections in your published document." ) doc.add_paragraph() # Section showing Heading 2 style (used for requirement items) doc.add_heading("SRS001: Requirement Item (Heading 2 Style)", level=2) doc.add_paragraph( "This style is used for requirement item headings. Each requirement " "shows its UID and optional header text using this style." ) doc.add_paragraph() # Show normal paragraph style doc.add_heading("SRS002: Another Requirement", level=2) doc.add_paragraph( "This is the Normal paragraph style, used for requirement body text. " "Customize font, size, and spacing to match your organization's " "document standards." ) doc.add_paragraph() # Show info item style (italic) doc.add_heading("SRS003: Info Item Example", level=2) info_para = doc.add_paragraph() info_run = info_para.add_run( "Info items are displayed in italic with muted color. This style is " "useful for explanatory notes that aren't formal requirements." ) info_run.italic = True info_run.font.color.rgb = RGBColor(0x7F, 0x8C, 0x8D) doc.add_paragraph() # Show links style doc.add_heading("SRS004: Item With Links", level=2) doc.add_paragraph("The system shall do something important.") links_para = doc.add_paragraph() links_run = links_para.add_run("Links: ") links_run.bold = True links_para.add_run("SYS001, SYS002") child_para = doc.add_paragraph() child_run = child_para.add_run("Linked from: ") child_run.bold = True child_para.add_run("UT001, UT002") doc.add_paragraph() # Tips section doc.add_heading("Tips for Customization", level=1) tips = [ "Open this template in Microsoft Word or compatible editor", "Modify styles via the Styles pane (don't just format text directly)", "Heading 1: Document sections and heading-type items", "Heading 2: Requirement item headings", "Normal: Body text for requirements", "Save your customized template and use with --template", ] for tip in tips: doc.add_paragraph(tip, style="List Bullet") # Add page numbers to show they work _add_page_numbers(doc) # Save the template doc.save(output_path)