Skip to content

API Reference

Auto-generated API documentation from source code docstrings.

Core

SmithDocument

papersmith.core.document.SmithDocument

High-level document creation and manipulation API.

Example

doc = SmithDocument(preset="academic") doc.add_heading("Introduction", level=1) doc.add_text("The time complexity is $O(n \log n)$.") doc.add_equation(r"T(n) = 2T(n/2) + n") doc.save("output.docx")

Parameters:

Name Type Description Default
preset str | dict[str, Any]

Style preset name or dict. Defaults to "academic".

'academic'
Source code in src/papersmith/core/document.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
class SmithDocument:
    """High-level document creation and manipulation API.

    Example:
        >>> doc = SmithDocument(preset="academic")
        >>> doc.add_heading("Introduction", level=1)
        >>> doc.add_text("The time complexity is $O(n \\\\log n)$.")
        >>> doc.add_equation(r"T(n) = 2T(n/2) + n")
        >>> doc.save("output.docx")

    Args:
        preset: Style preset name or dict. Defaults to "academic".
    """

    def __init__(self, preset: str | dict[str, Any] = "academic") -> None:
        self._doc: Document = docx.Document()
        self._preset: StylePreset = get_preset(preset)
        self._metadata = MetadataManager(self._doc)
        self._apply_preset()

    @classmethod
    def open(cls, path: str | Path) -> SmithDocument:
        """Open an existing Word document.

        Args:
            path: Path to the .docx file.

        Returns:
            A SmithDocument wrapping the opened document.
        """
        instance = cls.__new__(cls)
        instance._doc = docx.Document(str(path))
        instance._preset = get_preset("academic")  # Default preset for opened docs
        instance._metadata = MetadataManager(instance._doc)
        return instance

    def _apply_preset(self) -> None:
        """Apply the style preset to document page setup."""
        section = self._doc.sections[0]
        # Page size
        if self._preset.page_size == "A4":
            section.page_width = Cm(21.0)
            section.page_height = Cm(29.7)
        elif self._preset.page_size == "Letter":
            section.page_width = Inches(8.5)
            section.page_height = Inches(11)
        # Margins
        section.top_margin = Cm(self._preset.margins.top)
        section.bottom_margin = Cm(self._preset.margins.bottom)
        section.left_margin = Cm(self._preset.margins.left)
        section.right_margin = Cm(self._preset.margins.right)

    # ── Properties ────────────────────────────────────────────────────
    @property
    def metadata(self) -> MetadataManager:
        """Access the document metadata manager."""
        return self._metadata

    @property
    def preset(self) -> StylePreset:
        """Get the current style preset."""
        return self._preset

    # ── Content Methods ───────────────────────────────────────────────
    def add_heading(self, text: str, level: int = 1) -> SmithDocument:
        """Add a heading to the document.

        Args:
            text: Heading text.
            level: Heading level (1-6).

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_heading

        add_heading(self._doc, text, level, self._preset)
        return self

    def add_text(
        self,
        text: str,
        bold: bool = False,
        italic: bool = False,
        underline: bool = False,
    ) -> SmithDocument:
        """Add a paragraph of text.

        Supports inline LaTeX: wrap math in $...$ for inline equations.

        Args:
            text: Paragraph text.
            bold: Bold formatting.
            italic: Italic formatting.
            underline: Underline formatting.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_text

        add_text(self._doc, text, self._preset, bold=bold, italic=italic, underline=underline)
        return self

    def add_equation(self, latex: str) -> SmithDocument:
        """Add a display-mode LaTeX equation.

        The equation is rendered as native OMML, which is editable
        in Microsoft Word.

        Args:
            latex: LaTeX math expression (without $ delimiters).

        Returns:
            Self for method chaining.
        """
        from docx.enum.text import WD_ALIGN_PARAGRAPH

        paragraph = self._doc.add_paragraph()
        paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
        from papersmith.core.math import insert_equation

        insert_equation(paragraph, latex)
        return self

    def add_table(
        self,
        headers: list[str],
        rows: list[list[str]],
        style: str | None = None,
    ) -> SmithDocument:
        """Add a table to the document.

        Args:
            headers: Column header texts.
            rows: Row data (list of lists).
            style: Optional Word table style name.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_table

        add_table(self._doc, headers, rows, self._preset, style=style)
        return self

    def add_numbered_list(self, items: list[str]) -> SmithDocument:
        """Add a numbered list.

        Args:
            items: List item texts.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_numbered_list

        add_numbered_list(self._doc, items, self._preset)
        return self

    def add_bullet_list(self, items: list[str]) -> SmithDocument:
        """Add a bullet list.

        Args:
            items: List item texts.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_bullet_list

        add_bullet_list(self._doc, items, self._preset)
        return self

    def add_image(
        self,
        image: str | bytes | BytesIO,
        width: float | None = None,
        caption: str | None = None,
    ) -> SmithDocument:
        """Add an image to the document.

        Args:
            image: File path, bytes, or BytesIO of the image.
            width: Image width in centimeters.
            caption: Optional caption text.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_image

        add_image(self._doc, image, width=width, caption=caption, preset=self._preset)
        return self

    def add_page_break(self) -> SmithDocument:
        """Add a page break.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_page_break

        add_page_break(self._doc)
        return self

    def add_separator(self) -> SmithDocument:
        """Add a horizontal line separator.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_separator

        add_separator(self._doc)
        return self

    def add_code(self, code: str, language: str | None = None) -> SmithDocument:
        """Add a formatted code block.

        Args:
            code: Code text.
            language: Optional language name.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.elements import add_code_block

        add_code_block(self._doc, code, language=language, preset=self._preset)
        return self

    def add_cover_page(
        self,
        title: str,
        author: str | None = None,
        institution: str | None = None,
        date: str | None = None,
        subtitle: str | None = None,
    ) -> SmithDocument:
        """Add a styled cover page.

        Args:
            title: Document title.
            author: Author name.
            institution: Institution or organization.
            date: Date string (defaults to current date).
            subtitle: Optional subtitle.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.cover import add_cover_page

        add_cover_page(
            self._doc,
            self._preset,
            title=title,
            author=author,
            institution=institution,
            date=date,
            subtitle=subtitle,
        )
        return self

    def add_toc(self, title: str = "Table of Contents", max_level: int = 3) -> SmithDocument:
        """Add a Table of Contents.

        The TOC is populated when opened in Word (Update Field).

        Args:
            title: Title text above the TOC.
            max_level: Maximum heading level to include.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.toc import add_toc

        add_toc(self._doc, self._preset, title=title, max_level=max_level)
        return self

    # ── Headers & Footers ─────────────────────────────────────────────
    def set_header(self, text: str, first_page: str | None = None) -> SmithDocument:
        """Set the document header text.

        Args:
            text: Header text for all pages.
            first_page: Different text for first page (empty string = no header).

        Returns:
            Self for method chaining.
        """
        from papersmith.core.headers_footers import set_header

        set_header(self._doc, text, preset=self._preset, first_page=first_page)
        return self

    def set_footer(self, text: str) -> SmithDocument:
        """Set the document footer text.

        Args:
            text: Footer text.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.headers_footers import set_footer

        set_footer(self._doc, text, preset=self._preset)
        return self

    def add_page_numbers(
        self,
        position: str = "footer-center",
        format_str: str = "Page {page}",
    ) -> SmithDocument:
        """Add automatic page numbers.

        Args:
            position: Position string (e.g., "footer-center", "header-right").
            format_str: Format with {page} placeholder.

        Returns:
            Self for method chaining.
        """
        from papersmith.core.headers_footers import add_page_numbers

        add_page_numbers(self._doc, position=position, preset=self._preset, format_str=format_str)
        return self

    # ── Metadata ──────────────────────────────────────────────────────
    def set_metadata(
        self,
        author: str | None = None,
        title: str | None = None,
        subject: str | None = None,
        scrub: bool = False,
    ) -> SmithDocument:
        """Set document metadata.

        Args:
            author: Author name.
            title: Document title.
            subject: Document subject.
            scrub: If True, scrub all metadata for realistic values.

        Returns:
            Self for method chaining.
        """
        if author:
            self._metadata.author = author
        if title:
            self._metadata.title = title
        if subject:
            self._metadata.subject = subject
        if scrub:
            self._metadata.scrub(author=author)
        return self

    # ── Save & Export ─────────────────────────────────────────────────
    def save(self, path: str | Path) -> None:
        """Save the document as a .docx file.

        Args:
            path: Output file path.
        """
        self._doc.save(str(path))

    def to_bytes(self) -> bytes:
        """Export the document as bytes (no file I/O).

        Returns:
            The document as bytes.
        """
        buffer = BytesIO()
        self._doc.save(buffer)
        buffer.seek(0)
        return buffer.read()

    def to_stream(self) -> BytesIO:
        """Export the document as a BytesIO stream.

        Returns:
            BytesIO stream of the document.
        """
        buffer = BytesIO()
        self._doc.save(buffer)
        buffer.seek(0)
        return buffer

    def export_pdf(self, path: str | Path) -> None:
        """Export the document as PDF.

        Requires `docx2pdf` (which needs Office or LibreOffice).

        Args:
            path: Output PDF file path.

        Raises:
            ImportError: If docx2pdf is not installed.
        """
        # Save to a temp .docx first, then convert
        import tempfile

        from papersmith.export.pdf import export_pdf

        with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
            self._doc.save(tmp.name)
            export_pdf(tmp.name, str(path))
        # Clean up temp file
        Path(tmp.name).unlink(missing_ok=True)

    # ── Document Inspection ───────────────────────────────────────────
    def get_outline(self) -> list[dict[str, Any]]:
        """Get the document heading hierarchy.

        Returns:
            List of dicts with 'level' and 'text' keys.
        """
        outline: list[dict[str, Any]] = []
        for paragraph in self._doc.paragraphs:
            if (
                paragraph.style
                and paragraph.style.name
                and paragraph.style.name.startswith("Heading")
            ):
                try:
                    level = int(paragraph.style.name.split(" ")[-1])
                    outline.append({"level": level, "text": paragraph.text})
                except (ValueError, IndexError):
                    pass
        return outline

    def find_replace(self, old: str, new: str, count: int = 0) -> int:
        """Find and replace text in the document.

        Args:
            old: Text to find.
            new: Replacement text.
            count: Max replacements (0 = all).

        Returns:
            Number of replacements made.
        """
        replacements = 0
        for paragraph in self._doc.paragraphs:
            if old in paragraph.text:
                for run in paragraph.runs:
                    if old in run.text:
                        run.text = run.text.replace(old, new, 1 if count else -1)
                        replacements += 1
                        if count and replacements >= count:
                            return replacements
        return replacements

    # ── Section Editing ───────────────────────────────────────────────

    def get_section_text(self, heading: str) -> str | None:
        """Get the text content under a specific heading.

        Args:
            heading: The heading text to find.

        Returns:
            Concatenated text under the heading, or None if not found.
        """
        from papersmith.editing.navigator import get_section_text

        return get_section_text(self._doc, heading)

    def replace_section(self, heading: str, new_content: str) -> bool:
        """Replace the content under a heading (keeping the heading).

        Args:
            heading: The heading text of the section.
            new_content: New text content to replace with.

        Returns:
            True if the section was found and replaced, False otherwise.
        """
        from papersmith.editing.sections import replace_section_content

        return replace_section_content(self._doc, heading, new_content)

    def delete_section(self, heading: str) -> bool:
        """Delete a section (heading + content) from the document.

        Args:
            heading: The heading text of the section to delete.

        Returns:
            True if the section was found and deleted, False otherwise.
        """
        from papersmith.editing.sections import delete_section

        return delete_section(self._doc, heading)

    def get_full_text(self) -> str:
        """Get the full plain text of the document.

        Returns:
            All paragraph text joined by newlines.
        """
        return "\n".join(p.text for p in self._doc.paragraphs if p.text.strip())

    # ── Charts ────────────────────────────────────────────────────────

    def add_chart(
        self,
        chart_type: str,
        data: dict[str, Any],
        title: str | None = None,
        xlabel: str | None = None,
        ylabel: str | None = None,
        width: float = 12.0,
        caption: str | None = None,
    ) -> SmithDocument:
        """Add an embedded chart to the document.

        Args:
            chart_type: Type of chart (bar, line, pie, scatter).
            data: Chart data as dict (keys=labels, values=numbers).
            title: Chart title.
            xlabel: X-axis label.
            ylabel: Y-axis label.
            width: Image width in centimeters.
            caption: Optional caption text below the chart.

        Returns:
            Self for method chaining.
        """
        from papersmith.charts.renderer import render_chart

        png_bytes = render_chart(
            chart_type=chart_type,
            data=data,
            title=title,
            xlabel=xlabel,
            ylabel=ylabel,
        )
        self.add_image(BytesIO(png_bytes), width=width, caption=caption)
        return self

    # ── Citations ─────────────────────────────────────────────────────

    @property
    def citations(self) -> Any:
        """Access the citation manager for this document.

        Creates one on first access.
        """
        if not hasattr(self, "_citations"):
            from papersmith.citations.manager import CitationManager

            self._citations = CitationManager()
        return self._citations

    def add_bibliography(self) -> SmithDocument:
        """Add the bibliography section using all cited sources.

        Returns:
            Self for method chaining.
        """
        for entry in self.citations.get_bibliography():
            self.add_text(entry)
        return self

    def __repr__(self) -> str:
        """String representation."""
        paras = len(self._doc.paragraphs)
        return f"SmithDocument(preset='{self._preset.name}', paragraphs={paras})"

citations property

Access the citation manager for this document.

Creates one on first access.

metadata property

Access the document metadata manager.

preset property

Get the current style preset.

__repr__()

String representation.

Source code in src/papersmith/core/document.py
def __repr__(self) -> str:
    """String representation."""
    paras = len(self._doc.paragraphs)
    return f"SmithDocument(preset='{self._preset.name}', paragraphs={paras})"

add_bibliography()

Add the bibliography section using all cited sources.

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_bibliography(self) -> SmithDocument:
    """Add the bibliography section using all cited sources.

    Returns:
        Self for method chaining.
    """
    for entry in self.citations.get_bibliography():
        self.add_text(entry)
    return self

add_bullet_list(items)

Add a bullet list.

Parameters:

Name Type Description Default
items list[str]

List item texts.

required

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_bullet_list(self, items: list[str]) -> SmithDocument:
    """Add a bullet list.

    Args:
        items: List item texts.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_bullet_list

    add_bullet_list(self._doc, items, self._preset)
    return self

add_chart(chart_type, data, title=None, xlabel=None, ylabel=None, width=12.0, caption=None)

Add an embedded chart to the document.

Parameters:

Name Type Description Default
chart_type str

Type of chart (bar, line, pie, scatter).

required
data dict[str, Any]

Chart data as dict (keys=labels, values=numbers).

required
title str | None

Chart title.

None
xlabel str | None

X-axis label.

None
ylabel str | None

Y-axis label.

None
width float

Image width in centimeters.

12.0
caption str | None

Optional caption text below the chart.

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_chart(
    self,
    chart_type: str,
    data: dict[str, Any],
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    width: float = 12.0,
    caption: str | None = None,
) -> SmithDocument:
    """Add an embedded chart to the document.

    Args:
        chart_type: Type of chart (bar, line, pie, scatter).
        data: Chart data as dict (keys=labels, values=numbers).
        title: Chart title.
        xlabel: X-axis label.
        ylabel: Y-axis label.
        width: Image width in centimeters.
        caption: Optional caption text below the chart.

    Returns:
        Self for method chaining.
    """
    from papersmith.charts.renderer import render_chart

    png_bytes = render_chart(
        chart_type=chart_type,
        data=data,
        title=title,
        xlabel=xlabel,
        ylabel=ylabel,
    )
    self.add_image(BytesIO(png_bytes), width=width, caption=caption)
    return self

add_code(code, language=None)

Add a formatted code block.

Parameters:

Name Type Description Default
code str

Code text.

required
language str | None

Optional language name.

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_code(self, code: str, language: str | None = None) -> SmithDocument:
    """Add a formatted code block.

    Args:
        code: Code text.
        language: Optional language name.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_code_block

    add_code_block(self._doc, code, language=language, preset=self._preset)
    return self

add_cover_page(title, author=None, institution=None, date=None, subtitle=None)

Add a styled cover page.

Parameters:

Name Type Description Default
title str

Document title.

required
author str | None

Author name.

None
institution str | None

Institution or organization.

None
date str | None

Date string (defaults to current date).

None
subtitle str | None

Optional subtitle.

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_cover_page(
    self,
    title: str,
    author: str | None = None,
    institution: str | None = None,
    date: str | None = None,
    subtitle: str | None = None,
) -> SmithDocument:
    """Add a styled cover page.

    Args:
        title: Document title.
        author: Author name.
        institution: Institution or organization.
        date: Date string (defaults to current date).
        subtitle: Optional subtitle.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.cover import add_cover_page

    add_cover_page(
        self._doc,
        self._preset,
        title=title,
        author=author,
        institution=institution,
        date=date,
        subtitle=subtitle,
    )
    return self

add_equation(latex)

Add a display-mode LaTeX equation.

The equation is rendered as native OMML, which is editable in Microsoft Word.

Parameters:

Name Type Description Default
latex str

LaTeX math expression (without $ delimiters).

required

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_equation(self, latex: str) -> SmithDocument:
    """Add a display-mode LaTeX equation.

    The equation is rendered as native OMML, which is editable
    in Microsoft Word.

    Args:
        latex: LaTeX math expression (without $ delimiters).

    Returns:
        Self for method chaining.
    """
    from docx.enum.text import WD_ALIGN_PARAGRAPH

    paragraph = self._doc.add_paragraph()
    paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
    from papersmith.core.math import insert_equation

    insert_equation(paragraph, latex)
    return self

add_heading(text, level=1)

Add a heading to the document.

Parameters:

Name Type Description Default
text str

Heading text.

required
level int

Heading level (1-6).

1

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_heading(self, text: str, level: int = 1) -> SmithDocument:
    """Add a heading to the document.

    Args:
        text: Heading text.
        level: Heading level (1-6).

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_heading

    add_heading(self._doc, text, level, self._preset)
    return self

add_image(image, width=None, caption=None)

Add an image to the document.

Parameters:

Name Type Description Default
image str | bytes | BytesIO

File path, bytes, or BytesIO of the image.

required
width float | None

Image width in centimeters.

None
caption str | None

Optional caption text.

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_image(
    self,
    image: str | bytes | BytesIO,
    width: float | None = None,
    caption: str | None = None,
) -> SmithDocument:
    """Add an image to the document.

    Args:
        image: File path, bytes, or BytesIO of the image.
        width: Image width in centimeters.
        caption: Optional caption text.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_image

    add_image(self._doc, image, width=width, caption=caption, preset=self._preset)
    return self

add_numbered_list(items)

Add a numbered list.

Parameters:

Name Type Description Default
items list[str]

List item texts.

required

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_numbered_list(self, items: list[str]) -> SmithDocument:
    """Add a numbered list.

    Args:
        items: List item texts.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_numbered_list

    add_numbered_list(self._doc, items, self._preset)
    return self

add_page_break()

Add a page break.

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_page_break(self) -> SmithDocument:
    """Add a page break.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_page_break

    add_page_break(self._doc)
    return self

add_page_numbers(position='footer-center', format_str='Page {page}')

Add automatic page numbers.

Parameters:

Name Type Description Default
position str

Position string (e.g., "footer-center", "header-right").

'footer-center'
format_str str

Format with {page} placeholder.

'Page {page}'

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_page_numbers(
    self,
    position: str = "footer-center",
    format_str: str = "Page {page}",
) -> SmithDocument:
    """Add automatic page numbers.

    Args:
        position: Position string (e.g., "footer-center", "header-right").
        format_str: Format with {page} placeholder.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.headers_footers import add_page_numbers

    add_page_numbers(self._doc, position=position, preset=self._preset, format_str=format_str)
    return self

add_separator()

Add a horizontal line separator.

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_separator(self) -> SmithDocument:
    """Add a horizontal line separator.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_separator

    add_separator(self._doc)
    return self

add_table(headers, rows, style=None)

Add a table to the document.

Parameters:

Name Type Description Default
headers list[str]

Column header texts.

required
rows list[list[str]]

Row data (list of lists).

required
style str | None

Optional Word table style name.

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_table(
    self,
    headers: list[str],
    rows: list[list[str]],
    style: str | None = None,
) -> SmithDocument:
    """Add a table to the document.

    Args:
        headers: Column header texts.
        rows: Row data (list of lists).
        style: Optional Word table style name.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_table

    add_table(self._doc, headers, rows, self._preset, style=style)
    return self

add_text(text, bold=False, italic=False, underline=False)

Add a paragraph of text.

Supports inline LaTeX: wrap math in $...$ for inline equations.

Parameters:

Name Type Description Default
text str

Paragraph text.

required
bold bool

Bold formatting.

False
italic bool

Italic formatting.

False
underline bool

Underline formatting.

False

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_text(
    self,
    text: str,
    bold: bool = False,
    italic: bool = False,
    underline: bool = False,
) -> SmithDocument:
    """Add a paragraph of text.

    Supports inline LaTeX: wrap math in $...$ for inline equations.

    Args:
        text: Paragraph text.
        bold: Bold formatting.
        italic: Italic formatting.
        underline: Underline formatting.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.elements import add_text

    add_text(self._doc, text, self._preset, bold=bold, italic=italic, underline=underline)
    return self

add_toc(title='Table of Contents', max_level=3)

Add a Table of Contents.

The TOC is populated when opened in Word (Update Field).

Parameters:

Name Type Description Default
title str

Title text above the TOC.

'Table of Contents'
max_level int

Maximum heading level to include.

3

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def add_toc(self, title: str = "Table of Contents", max_level: int = 3) -> SmithDocument:
    """Add a Table of Contents.

    The TOC is populated when opened in Word (Update Field).

    Args:
        title: Title text above the TOC.
        max_level: Maximum heading level to include.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.toc import add_toc

    add_toc(self._doc, self._preset, title=title, max_level=max_level)
    return self

delete_section(heading)

Delete a section (heading + content) from the document.

Parameters:

Name Type Description Default
heading str

The heading text of the section to delete.

required

Returns:

Type Description
bool

True if the section was found and deleted, False otherwise.

Source code in src/papersmith/core/document.py
def delete_section(self, heading: str) -> bool:
    """Delete a section (heading + content) from the document.

    Args:
        heading: The heading text of the section to delete.

    Returns:
        True if the section was found and deleted, False otherwise.
    """
    from papersmith.editing.sections import delete_section

    return delete_section(self._doc, heading)

export_pdf(path)

Export the document as PDF.

Requires docx2pdf (which needs Office or LibreOffice).

Parameters:

Name Type Description Default
path str | Path

Output PDF file path.

required

Raises:

Type Description
ImportError

If docx2pdf is not installed.

Source code in src/papersmith/core/document.py
def export_pdf(self, path: str | Path) -> None:
    """Export the document as PDF.

    Requires `docx2pdf` (which needs Office or LibreOffice).

    Args:
        path: Output PDF file path.

    Raises:
        ImportError: If docx2pdf is not installed.
    """
    # Save to a temp .docx first, then convert
    import tempfile

    from papersmith.export.pdf import export_pdf

    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
        self._doc.save(tmp.name)
        export_pdf(tmp.name, str(path))
    # Clean up temp file
    Path(tmp.name).unlink(missing_ok=True)

find_replace(old, new, count=0)

Find and replace text in the document.

Parameters:

Name Type Description Default
old str

Text to find.

required
new str

Replacement text.

required
count int

Max replacements (0 = all).

0

Returns:

Type Description
int

Number of replacements made.

Source code in src/papersmith/core/document.py
def find_replace(self, old: str, new: str, count: int = 0) -> int:
    """Find and replace text in the document.

    Args:
        old: Text to find.
        new: Replacement text.
        count: Max replacements (0 = all).

    Returns:
        Number of replacements made.
    """
    replacements = 0
    for paragraph in self._doc.paragraphs:
        if old in paragraph.text:
            for run in paragraph.runs:
                if old in run.text:
                    run.text = run.text.replace(old, new, 1 if count else -1)
                    replacements += 1
                    if count and replacements >= count:
                        return replacements
    return replacements

get_full_text()

Get the full plain text of the document.

Returns:

Type Description
str

All paragraph text joined by newlines.

Source code in src/papersmith/core/document.py
def get_full_text(self) -> str:
    """Get the full plain text of the document.

    Returns:
        All paragraph text joined by newlines.
    """
    return "\n".join(p.text for p in self._doc.paragraphs if p.text.strip())

get_outline()

Get the document heading hierarchy.

Returns:

Type Description
list[dict[str, Any]]

List of dicts with 'level' and 'text' keys.

Source code in src/papersmith/core/document.py
def get_outline(self) -> list[dict[str, Any]]:
    """Get the document heading hierarchy.

    Returns:
        List of dicts with 'level' and 'text' keys.
    """
    outline: list[dict[str, Any]] = []
    for paragraph in self._doc.paragraphs:
        if (
            paragraph.style
            and paragraph.style.name
            and paragraph.style.name.startswith("Heading")
        ):
            try:
                level = int(paragraph.style.name.split(" ")[-1])
                outline.append({"level": level, "text": paragraph.text})
            except (ValueError, IndexError):
                pass
    return outline

get_section_text(heading)

Get the text content under a specific heading.

Parameters:

Name Type Description Default
heading str

The heading text to find.

required

Returns:

Type Description
str | None

Concatenated text under the heading, or None if not found.

Source code in src/papersmith/core/document.py
def get_section_text(self, heading: str) -> str | None:
    """Get the text content under a specific heading.

    Args:
        heading: The heading text to find.

    Returns:
        Concatenated text under the heading, or None if not found.
    """
    from papersmith.editing.navigator import get_section_text

    return get_section_text(self._doc, heading)

open(path) classmethod

Open an existing Word document.

Parameters:

Name Type Description Default
path str | Path

Path to the .docx file.

required

Returns:

Type Description
SmithDocument

A SmithDocument wrapping the opened document.

Source code in src/papersmith/core/document.py
@classmethod
def open(cls, path: str | Path) -> SmithDocument:
    """Open an existing Word document.

    Args:
        path: Path to the .docx file.

    Returns:
        A SmithDocument wrapping the opened document.
    """
    instance = cls.__new__(cls)
    instance._doc = docx.Document(str(path))
    instance._preset = get_preset("academic")  # Default preset for opened docs
    instance._metadata = MetadataManager(instance._doc)
    return instance

replace_section(heading, new_content)

Replace the content under a heading (keeping the heading).

Parameters:

Name Type Description Default
heading str

The heading text of the section.

required
new_content str

New text content to replace with.

required

Returns:

Type Description
bool

True if the section was found and replaced, False otherwise.

Source code in src/papersmith/core/document.py
def replace_section(self, heading: str, new_content: str) -> bool:
    """Replace the content under a heading (keeping the heading).

    Args:
        heading: The heading text of the section.
        new_content: New text content to replace with.

    Returns:
        True if the section was found and replaced, False otherwise.
    """
    from papersmith.editing.sections import replace_section_content

    return replace_section_content(self._doc, heading, new_content)

save(path)

Save the document as a .docx file.

Parameters:

Name Type Description Default
path str | Path

Output file path.

required
Source code in src/papersmith/core/document.py
def save(self, path: str | Path) -> None:
    """Save the document as a .docx file.

    Args:
        path: Output file path.
    """
    self._doc.save(str(path))

Set the document footer text.

Parameters:

Name Type Description Default
text str

Footer text.

required

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def set_footer(self, text: str) -> SmithDocument:
    """Set the document footer text.

    Args:
        text: Footer text.

    Returns:
        Self for method chaining.
    """
    from papersmith.core.headers_footers import set_footer

    set_footer(self._doc, text, preset=self._preset)
    return self

set_header(text, first_page=None)

Set the document header text.

Parameters:

Name Type Description Default
text str

Header text for all pages.

required
first_page str | None

Different text for first page (empty string = no header).

None

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def set_header(self, text: str, first_page: str | None = None) -> SmithDocument:
    """Set the document header text.

    Args:
        text: Header text for all pages.
        first_page: Different text for first page (empty string = no header).

    Returns:
        Self for method chaining.
    """
    from papersmith.core.headers_footers import set_header

    set_header(self._doc, text, preset=self._preset, first_page=first_page)
    return self

set_metadata(author=None, title=None, subject=None, scrub=False)

Set document metadata.

Parameters:

Name Type Description Default
author str | None

Author name.

None
title str | None

Document title.

None
subject str | None

Document subject.

None
scrub bool

If True, scrub all metadata for realistic values.

False

Returns:

Type Description
SmithDocument

Self for method chaining.

Source code in src/papersmith/core/document.py
def set_metadata(
    self,
    author: str | None = None,
    title: str | None = None,
    subject: str | None = None,
    scrub: bool = False,
) -> SmithDocument:
    """Set document metadata.

    Args:
        author: Author name.
        title: Document title.
        subject: Document subject.
        scrub: If True, scrub all metadata for realistic values.

    Returns:
        Self for method chaining.
    """
    if author:
        self._metadata.author = author
    if title:
        self._metadata.title = title
    if subject:
        self._metadata.subject = subject
    if scrub:
        self._metadata.scrub(author=author)
    return self

to_bytes()

Export the document as bytes (no file I/O).

Returns:

Type Description
bytes

The document as bytes.

Source code in src/papersmith/core/document.py
def to_bytes(self) -> bytes:
    """Export the document as bytes (no file I/O).

    Returns:
        The document as bytes.
    """
    buffer = BytesIO()
    self._doc.save(buffer)
    buffer.seek(0)
    return buffer.read()

to_stream()

Export the document as a BytesIO stream.

Returns:

Type Description
BytesIO

BytesIO stream of the document.

Source code in src/papersmith/core/document.py
def to_stream(self) -> BytesIO:
    """Export the document as a BytesIO stream.

    Returns:
        BytesIO stream of the document.
    """
    buffer = BytesIO()
    self._doc.save(buffer)
    buffer.seek(0)
    return buffer

StylePreset

papersmith.core.styles.StylePreset dataclass

Complete style configuration for a document.

Source code in src/papersmith/core/styles.py
@dataclass(frozen=True)
class StylePreset:
    """Complete style configuration for a document."""

    name: str
    font: str = "Times New Roman"
    font_size: int = 12
    heading_font: str = "Times New Roman"
    page_size: str = "A4"
    margins: Margins = field(default_factory=Margins)
    color: str = "#000000"
    line_spacing: float = 1.5
    heading_color: str = "#000000"
    paragraph_spacing_after: int = 8
    first_line_indent: float = 0.0
    justify: bool = False

    def to_dict(self) -> dict[str, Any]:
        """Convert preset to a dictionary."""
        result: dict[str, Any] = {
            "name": self.name,
            "font": self.font,
            "font_size": self.font_size,
            "heading_font": self.heading_font,
            "page_size": self.page_size,
            "margins": {
                "top": self.margins.top,
                "bottom": self.margins.bottom,
                "left": self.margins.left,
                "right": self.margins.right,
            },
            "color": self.color,
            "line_spacing": self.line_spacing,
            "heading_color": self.heading_color,
            "paragraph_spacing_after": self.paragraph_spacing_after,
            "first_line_indent": self.first_line_indent,
            "justify": self.justify,
        }
        return result

to_dict()

Convert preset to a dictionary.

Source code in src/papersmith/core/styles.py
def to_dict(self) -> dict[str, Any]:
    """Convert preset to a dictionary."""
    result: dict[str, Any] = {
        "name": self.name,
        "font": self.font,
        "font_size": self.font_size,
        "heading_font": self.heading_font,
        "page_size": self.page_size,
        "margins": {
            "top": self.margins.top,
            "bottom": self.margins.bottom,
            "left": self.margins.left,
            "right": self.margins.right,
        },
        "color": self.color,
        "line_spacing": self.line_spacing,
        "heading_color": self.heading_color,
        "paragraph_spacing_after": self.paragraph_spacing_after,
        "first_line_indent": self.first_line_indent,
        "justify": self.justify,
    }
    return result

Margins

papersmith.core.styles.Margins dataclass

Page margins in centimeters.

Source code in src/papersmith/core/styles.py
@dataclass(frozen=True)
class Margins:
    """Page margins in centimeters."""

    top: float = 2.54
    bottom: float = 2.54
    left: float = 2.54
    right: float = 2.54

Citations

CitationManager

papersmith.citations.manager.CitationManager

Manages citation sources and formatting for a document.

Supports multiple citation styles (APA, IEEE, Harvard, MLA, Chicago) and provides methods to add sources, resolve inline citations, and generate bibliography sections.

Example

manager = CitationManager() manager.style = "ieee" manager.add_source("knuth1997", author="Donald Knuth", title="TAOCP", year=1997) manager.format_inline("knuth1997") '[1]'

Source code in src/papersmith/citations/manager.py
class CitationManager:
    """Manages citation sources and formatting for a document.

    Supports multiple citation styles (APA, IEEE, Harvard, MLA, Chicago)
    and provides methods to add sources, resolve inline citations,
    and generate bibliography sections.

    Example:
        >>> manager = CitationManager()
        >>> manager.style = "ieee"
        >>> manager.add_source("knuth1997", author="Donald Knuth", title="TAOCP", year=1997)
        >>> manager.format_inline("knuth1997")
        '[1]'
    """

    def __init__(self) -> None:
        self._sources: dict[str, Source] = {}
        self._style: str = "apa"
        self._citation_order: list[str] = []  # For IEEE-style numbering

    @property
    def style(self) -> str:
        """Get the current citation style."""
        return self._style

    @style.setter
    def style(self, value: str) -> None:
        """Set the citation style.

        Args:
            value: Style name (apa, ieee, harvard, mla, chicago).

        Raises:
            ValueError: If the style is not supported.
        """
        valid = {"apa", "ieee", "harvard", "mla", "chicago"}
        if value.lower() not in valid:
            msg = f"Unknown citation style '{value}'. Available: {', '.join(sorted(valid))}"
            raise ValueError(msg)
        self._style = value.lower()

    def add_source(self, key: str, **kwargs: Any) -> None:
        """Register a citation source.

        Args:
            key: Unique source identifier.
            **kwargs: Source fields (author, title, year, etc.).
        """
        self._sources[key] = Source(key=key, **kwargs)

    def get_source(self, key: str) -> Source:
        """Get a source by key.

        Args:
            key: Source identifier.

        Returns:
            The Source object.

        Raises:
            KeyError: If the source is not found.
        """
        if key not in self._sources:
            msg = (
                f"Citation source '{key}' not found. Available: {', '.join(self._sources.keys())}"
            )
            raise KeyError(msg)
        return self._sources[key]

    def format_inline(self, key: str) -> str:
        """Format an inline citation for the given source.

        Args:
            key: Source identifier.

        Returns:
            Formatted inline citation string.
        """
        source = self.get_source(key)
        # Track citation order for numbered styles
        if key not in self._citation_order:
            self._citation_order.append(key)
        formatter = self._get_formatter()
        return formatter.format_inline(source, self._citation_order.index(key) + 1)  # type: ignore[no-any-return]

    def format_bibliography_entry(self, key: str) -> str:
        """Format a single bibliography entry.

        Args:
            key: Source identifier.

        Returns:
            Formatted bibliography string.
        """
        source = self.get_source(key)
        number = self._citation_order.index(key) + 1 if key in self._citation_order else 0
        formatter = self._get_formatter()
        return formatter.format_bibliography(source, number)  # type: ignore[no-any-return]

    def get_bibliography(self) -> list[str]:
        """Get all bibliography entries in order.

        Returns:
            List of formatted bibliography strings.
        """
        formatter = self._get_formatter()
        entries: list[str] = []
        # Use citation order for numbered styles, alphabetical for others
        if self._style == "ieee":
            keys = self._citation_order
        else:
            keys = sorted(
                self._sources.keys(),
                key=lambda k: self._sources[k].get_last_name().lower(),
            )
        for i, key in enumerate(keys, 1):
            source = self._sources[key]
            entries.append(formatter.format_bibliography(source, i))
        return entries

    def import_bibtex(self, path: str) -> int:
        """Import sources from a BibTeX file.

        Args:
            path: Path to the .bib file.

        Returns:
            Number of sources imported.

        Raises:
            ImportError: If bibtexparser is not installed.
        """
        from papersmith.citations.bibtex import parse_bibtex_file

        sources = parse_bibtex_file(path)
        for source in sources:
            self._sources[source.key] = source
        return len(sources)

    def _get_formatter(self) -> Any:
        """Get the citation formatter for the current style."""
        from papersmith.citations.styles import get_formatter

        return get_formatter(self._style)

    @property
    def sources(self) -> dict[str, Source]:
        """Get all registered sources."""
        return dict(self._sources)

    def resolve_citations(self, text: str) -> str:
        """Resolve {cite:key} markers in text to formatted citations.

        Args:
            text: Text containing {cite:key} markers.

        Returns:
            Text with citations resolved.
        """
        import re

        def _replace(match: re.Match[str]) -> str:
            keys = match.group(1).split(",")
            citations = []
            for key in keys:
                key = key.strip()
                try:
                    citations.append(self.format_inline(key))
                except KeyError:
                    citations.append(f"[{key}?]")
            if self._style == "ieee":
                # Combine IEEE numbers: [1], [2] → [1, 2]
                nums = [c.strip("[]") for c in citations]
                return f"[{', '.join(nums)}]"
            return "; ".join(citations)

        return re.sub(r"\{cite:([^}]+)\}", _replace, text)

sources property

Get all registered sources.

style property writable

Get the current citation style.

add_source(key, **kwargs)

Register a citation source.

Parameters:

Name Type Description Default
key str

Unique source identifier.

required
**kwargs Any

Source fields (author, title, year, etc.).

{}
Source code in src/papersmith/citations/manager.py
def add_source(self, key: str, **kwargs: Any) -> None:
    """Register a citation source.

    Args:
        key: Unique source identifier.
        **kwargs: Source fields (author, title, year, etc.).
    """
    self._sources[key] = Source(key=key, **kwargs)

format_bibliography_entry(key)

Format a single bibliography entry.

Parameters:

Name Type Description Default
key str

Source identifier.

required

Returns:

Type Description
str

Formatted bibliography string.

Source code in src/papersmith/citations/manager.py
def format_bibliography_entry(self, key: str) -> str:
    """Format a single bibliography entry.

    Args:
        key: Source identifier.

    Returns:
        Formatted bibliography string.
    """
    source = self.get_source(key)
    number = self._citation_order.index(key) + 1 if key in self._citation_order else 0
    formatter = self._get_formatter()
    return formatter.format_bibliography(source, number)  # type: ignore[no-any-return]

format_inline(key)

Format an inline citation for the given source.

Parameters:

Name Type Description Default
key str

Source identifier.

required

Returns:

Type Description
str

Formatted inline citation string.

Source code in src/papersmith/citations/manager.py
def format_inline(self, key: str) -> str:
    """Format an inline citation for the given source.

    Args:
        key: Source identifier.

    Returns:
        Formatted inline citation string.
    """
    source = self.get_source(key)
    # Track citation order for numbered styles
    if key not in self._citation_order:
        self._citation_order.append(key)
    formatter = self._get_formatter()
    return formatter.format_inline(source, self._citation_order.index(key) + 1)  # type: ignore[no-any-return]

get_bibliography()

Get all bibliography entries in order.

Returns:

Type Description
list[str]

List of formatted bibliography strings.

Source code in src/papersmith/citations/manager.py
def get_bibliography(self) -> list[str]:
    """Get all bibliography entries in order.

    Returns:
        List of formatted bibliography strings.
    """
    formatter = self._get_formatter()
    entries: list[str] = []
    # Use citation order for numbered styles, alphabetical for others
    if self._style == "ieee":
        keys = self._citation_order
    else:
        keys = sorted(
            self._sources.keys(),
            key=lambda k: self._sources[k].get_last_name().lower(),
        )
    for i, key in enumerate(keys, 1):
        source = self._sources[key]
        entries.append(formatter.format_bibliography(source, i))
    return entries

get_source(key)

Get a source by key.

Parameters:

Name Type Description Default
key str

Source identifier.

required

Returns:

Type Description
Source

The Source object.

Raises:

Type Description
KeyError

If the source is not found.

Source code in src/papersmith/citations/manager.py
def get_source(self, key: str) -> Source:
    """Get a source by key.

    Args:
        key: Source identifier.

    Returns:
        The Source object.

    Raises:
        KeyError: If the source is not found.
    """
    if key not in self._sources:
        msg = (
            f"Citation source '{key}' not found. Available: {', '.join(self._sources.keys())}"
        )
        raise KeyError(msg)
    return self._sources[key]

import_bibtex(path)

Import sources from a BibTeX file.

Parameters:

Name Type Description Default
path str

Path to the .bib file.

required

Returns:

Type Description
int

Number of sources imported.

Raises:

Type Description
ImportError

If bibtexparser is not installed.

Source code in src/papersmith/citations/manager.py
def import_bibtex(self, path: str) -> int:
    """Import sources from a BibTeX file.

    Args:
        path: Path to the .bib file.

    Returns:
        Number of sources imported.

    Raises:
        ImportError: If bibtexparser is not installed.
    """
    from papersmith.citations.bibtex import parse_bibtex_file

    sources = parse_bibtex_file(path)
    for source in sources:
        self._sources[source.key] = source
    return len(sources)

resolve_citations(text)

Resolve {cite:key} markers in text to formatted citations.

Parameters:

Name Type Description Default
text str

Text containing {cite:key} markers.

required

Returns:

Type Description
str

Text with citations resolved.

Source code in src/papersmith/citations/manager.py
def resolve_citations(self, text: str) -> str:
    """Resolve {cite:key} markers in text to formatted citations.

    Args:
        text: Text containing {cite:key} markers.

    Returns:
        Text with citations resolved.
    """
    import re

    def _replace(match: re.Match[str]) -> str:
        keys = match.group(1).split(",")
        citations = []
        for key in keys:
            key = key.strip()
            try:
                citations.append(self.format_inline(key))
            except KeyError:
                citations.append(f"[{key}?]")
        if self._style == "ieee":
            # Combine IEEE numbers: [1], [2] → [1, 2]
            nums = [c.strip("[]") for c in citations]
            return f"[{', '.join(nums)}]"
        return "; ".join(citations)

    return re.sub(r"\{cite:([^}]+)\}", _replace, text)

Source

papersmith.citations.source.Source dataclass

Represents a citation source (book, article, etc.).

Parameters:

Name Type Description Default
key str

Unique identifier for the source (e.g., "knuth1997").

required
source_type str

Type of source (article, book, inproceedings, etc.).

'article'
author str

Author name(s).

''
title str

Title of the work.

''
year int | None

Publication year.

None
journal str | None

Journal name (for articles).

None
publisher str | None

Publisher name (for books).

None
volume str | None

Volume number.

None
issue str | None

Issue number.

None
pages str | None

Page range (e.g., "10-25").

None
doi str | None

Digital Object Identifier.

None
url str | None

URL reference.

None
isbn str | None

ISBN number.

None
booktitle str | None

Book title (for inproceedings).

None
editor str | None

Editor name(s).

None
edition str | None

Edition number.

None
institution str | None

Institution name.

None
extra dict[str, Any]

Additional fields.

dict()
Source code in src/papersmith/citations/source.py
@dataclass
class Source:
    """Represents a citation source (book, article, etc.).

    Args:
        key: Unique identifier for the source (e.g., "knuth1997").
        source_type: Type of source (article, book, inproceedings, etc.).
        author: Author name(s).
        title: Title of the work.
        year: Publication year.
        journal: Journal name (for articles).
        publisher: Publisher name (for books).
        volume: Volume number.
        issue: Issue number.
        pages: Page range (e.g., "10-25").
        doi: Digital Object Identifier.
        url: URL reference.
        isbn: ISBN number.
        booktitle: Book title (for inproceedings).
        editor: Editor name(s).
        edition: Edition number.
        institution: Institution name.
        extra: Additional fields.
    """

    key: str
    source_type: str = "article"
    author: str = ""
    title: str = ""
    year: int | None = None
    journal: str | None = None
    publisher: str | None = None
    volume: str | None = None
    issue: str | None = None
    pages: str | None = None
    doi: str | None = None
    url: str | None = None
    isbn: str | None = None
    booktitle: str | None = None
    editor: str | None = None
    edition: str | None = None
    institution: str | None = None
    extra: dict[str, Any] = field(default_factory=dict)

    def get_authors_list(self) -> list[str]:
        """Parse the author string into a list of individual authors.

        Handles "and" separated authors (BibTeX style).

        Returns:
            List of author name strings.
        """
        if not self.author:
            return []
        # Split on " and " (BibTeX convention)
        return [a.strip() for a in self.author.replace(" and ", " & ").split(" & ")]

    def get_last_name(self) -> str:
        """Get the last name of the first author.

        Returns:
            Last name string, or empty string if no author.
        """
        authors = self.get_authors_list()
        if not authors:
            return ""
        first_author = authors[0]
        # Handle "Last, First" format
        if "," in first_author:
            return first_author.split(",")[0].strip()
        # Handle "First Last" format
        parts = first_author.split()
        return parts[-1] if parts else ""

get_authors_list()

Parse the author string into a list of individual authors.

Handles "and" separated authors (BibTeX style).

Returns:

Type Description
list[str]

List of author name strings.

Source code in src/papersmith/citations/source.py
def get_authors_list(self) -> list[str]:
    """Parse the author string into a list of individual authors.

    Handles "and" separated authors (BibTeX style).

    Returns:
        List of author name strings.
    """
    if not self.author:
        return []
    # Split on " and " (BibTeX convention)
    return [a.strip() for a in self.author.replace(" and ", " & ").split(" & ")]

get_last_name()

Get the last name of the first author.

Returns:

Type Description
str

Last name string, or empty string if no author.

Source code in src/papersmith/citations/source.py
def get_last_name(self) -> str:
    """Get the last name of the first author.

    Returns:
        Last name string, or empty string if no author.
    """
    authors = self.get_authors_list()
    if not authors:
        return ""
    first_author = authors[0]
    # Handle "Last, First" format
    if "," in first_author:
        return first_author.split(",")[0].strip()
    # Handle "First Last" format
    parts = first_author.split()
    return parts[-1] if parts else ""

Charts

render_chart

papersmith.charts.renderer.render_chart(chart_type, data, title=None, xlabel=None, ylabel=None, color_scheme='professional', width=6.0, height=4.0, dpi=300)

Render a chart to PNG bytes.

Parameters:

Name Type Description Default
chart_type str

Type of chart (bar, line, pie, scatter).

required
data dict[str, Any]

Chart data as a dict (keys=labels, values=numbers).

required
title str | None

Chart title.

None
xlabel str | None

X-axis label.

None
ylabel str | None

Y-axis label.

None
color_scheme str

Color palette name.

'professional'
width float

Figure width in inches.

6.0
height float

Figure height in inches.

4.0
dpi int

Output resolution.

300

Returns:

Type Description
bytes

PNG image bytes.

Raises:

Type Description
ImportError

If matplotlib is not installed.

ValueError

If chart_type is not supported.

Source code in src/papersmith/charts/renderer.py
def render_chart(
    chart_type: str,
    data: dict[str, Any],
    title: str | None = None,
    xlabel: str | None = None,
    ylabel: str | None = None,
    color_scheme: str = "professional",
    width: float = 6.0,
    height: float = 4.0,
    dpi: int = 300,
) -> bytes:
    """Render a chart to PNG bytes.

    Args:
        chart_type: Type of chart (bar, line, pie, scatter).
        data: Chart data as a dict (keys=labels, values=numbers).
        title: Chart title.
        xlabel: X-axis label.
        ylabel: Y-axis label.
        color_scheme: Color palette name.
        width: Figure width in inches.
        height: Figure height in inches.
        dpi: Output resolution.

    Returns:
        PNG image bytes.

    Raises:
        ImportError: If matplotlib is not installed.
        ValueError: If chart_type is not supported.
    """
    try:
        import matplotlib

        matplotlib.use("Agg")  # Non-interactive backend
        import matplotlib.pyplot as plt
    except ImportError as e:
        msg = "matplotlib is required for charts. Install with: pip install papersmith"
        raise ImportError(msg) from e
    colors = get_palette(color_scheme)
    fig, ax = plt.subplots(figsize=(width, height))
    labels = list(data.keys())
    values = list(data.values())
    if chart_type == "bar":
        bar_colors = [colors[i % len(colors)] for i in range(len(labels))]
        ax.bar(labels, values, color=bar_colors, edgecolor="white", linewidth=0.5)
    elif chart_type == "line":
        ax.plot(labels, values, color=colors[0], marker="o", linewidth=2, markersize=6)
        ax.fill_between(range(len(labels)), values, alpha=0.1, color=colors[0])
    elif chart_type == "pie":
        pie_colors = [colors[i % len(colors)] for i in range(len(labels))]
        ax.pie(values, labels=labels, colors=pie_colors, autopct="%1.1f%%", startangle=90)
        ax.set_aspect("equal")
    elif chart_type == "scatter":
        ax.scatter(
            range(len(labels)), values, color=colors[0], s=60, edgecolors="white", linewidth=0.5
        )
        ax.set_xticks(range(len(labels)))
        ax.set_xticklabels(labels)
    else:
        plt.close(fig)
        msg = f"Unsupported chart type: {chart_type}. Use: bar, line, pie, scatter"
        raise ValueError(msg)
    if title:
        ax.set_title(title, fontsize=12, fontweight="bold", pad=10)
    if xlabel:
        ax.set_xlabel(xlabel, fontsize=10)
    if ylabel:
        ax.set_ylabel(ylabel, fontsize=10)
    # Clean up axes
    if chart_type != "pie":
        ax.spines["top"].set_visible(False)
        ax.spines["right"].set_visible(False)
        ax.tick_params(labelsize=9)
    fig.tight_layout()
    # Render to bytes
    buffer = BytesIO()
    fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    buffer.seek(0)
    return buffer.read()

render_matplotlib_figure

papersmith.charts.renderer.render_matplotlib_figure(fig, dpi=300)

Render an existing matplotlib Figure to PNG bytes.

Parameters:

Name Type Description Default
fig Any

A matplotlib Figure object.

required
dpi int

Output resolution.

300

Returns:

Type Description
bytes

PNG image bytes.

Source code in src/papersmith/charts/renderer.py
def render_matplotlib_figure(fig: Any, dpi: int = 300) -> bytes:
    """Render an existing matplotlib Figure to PNG bytes.

    Args:
        fig: A matplotlib Figure object.
        dpi: Output resolution.

    Returns:
        PNG image bytes.
    """
    import matplotlib.pyplot as plt

    buffer = BytesIO()
    fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    buffer.seek(0)
    return buffer.read()

Metadata

MetadataManager

papersmith.core.metadata.MetadataManager

Manages document metadata (author, title, dates, etc.).

Provides methods to set individual properties and to scrub all metadata to remove signs of programmatic generation.

Parameters:

Name Type Description Default
doc Document

The python-docx Document whose metadata to manage.

required
Source code in src/papersmith/core/metadata.py
class MetadataManager:
    """Manages document metadata (author, title, dates, etc.).

    Provides methods to set individual properties and to scrub
    all metadata to remove signs of programmatic generation.

    Args:
        doc: The python-docx Document whose metadata to manage.
    """

    def __init__(self, doc: Document) -> None:
        self._doc = doc

    @property
    def _core(self) -> Any:
        """Access the core properties of the document."""
        return self._doc.core_properties

    # ── Properties ────────────────────────────────────────────────────

    @property
    def author(self) -> str | None:
        """Get the document author."""
        return self._core.author  # type: ignore[no-any-return]

    @author.setter
    def author(self, value: str) -> None:
        """Set the document author."""
        self._core.author = value

    @property
    def title(self) -> str | None:
        """Get the document title."""
        return self._core.title  # type: ignore[no-any-return]

    @title.setter
    def title(self, value: str) -> None:
        """Set the document title."""
        self._core.title = value

    @property
    def subject(self) -> str | None:
        """Get the document subject."""
        return self._core.subject  # type: ignore[no-any-return]

    @subject.setter
    def subject(self, value: str) -> None:
        """Set the document subject."""
        self._core.subject = value

    @property
    def keywords(self) -> str | None:
        """Get the document keywords."""
        return self._core.keywords  # type: ignore[no-any-return]

    @keywords.setter
    def keywords(self, value: str) -> None:
        """Set the document keywords."""
        self._core.keywords = value

    @property
    def created(self) -> datetime | None:
        """Get the creation date."""
        return self._core.created  # type: ignore[no-any-return]

    @created.setter
    def created(self, value: datetime) -> None:
        """Set the creation date."""
        self._core.created = value

    @property
    def modified(self) -> datetime | None:
        """Get the last modified date."""
        return self._core.modified  # type: ignore[no-any-return]

    @modified.setter
    def modified(self, value: datetime) -> None:
        """Set the last modified date."""
        self._core.modified = value

    @property
    def last_modified_by(self) -> str | None:
        """Get the last modified by name."""
        return self._core.last_modified_by  # type: ignore[no-any-return]

    @last_modified_by.setter
    def last_modified_by(self, value: str) -> None:
        """Set the last modified by name."""
        self._core.last_modified_by = value

    @property
    def revision(self) -> int | None:
        """Get the revision number."""
        return self._core.revision  # type: ignore[no-any-return]

    @revision.setter
    def revision(self, value: int) -> None:
        """Set the revision number."""
        self._core.revision = value

    # ── High-level Methods ────────────────────────────────────────────

    def set_application(
        self, name: str = "Microsoft Office Word", version: str = "16.0000"
    ) -> None:
        """Set the application name and version in extended properties.

        Note: python-docx doesn't directly expose app.xml properties,
        so this modifies the XML directly.

        Args:
            name: Application name.
            version: Application version string.
        """
        from lxml import etree

        # Access the app.xml part if it exists
        try:
            app_part = self._doc.part.package.part_related_by(
                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"
            )
            root = app_part._element
            nsmap = root.nsmap

            # Find or create Application element
            ns = nsmap.get(
                None, "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
            )
            app_el = root.find(f"{{{ns}}}Application")
            if app_el is not None:
                app_el.text = name
            else:
                app_el = etree.SubElement(root, f"{{{ns}}}Application")
                app_el.text = name

            ver_el = root.find(f"{{{ns}}}AppVersion")
            if ver_el is not None:
                ver_el.text = version
            else:
                ver_el = etree.SubElement(root, f"{{{ns}}}AppVersion")
                ver_el.text = version
        except Exception:
            # If app.xml doesn't exist or can't be modified, skip silently
            pass

    def scrub(
        self,
        author: str | None = None,
        realistic_dates: bool = True,
        editing_time_minutes: int | None = None,
    ) -> None:
        """Scrub all metadata to remove programmatic generation fingerprints.

        Sets realistic values that make the document appear hand-authored
        in Microsoft Word.

        Args:
            author: Author name to set. If None, uses existing or "User".
            realistic_dates: Generate realistic creation/modified dates.
            editing_time_minutes: Total editing time. Random 30-180 if None.
        """
        # Set author
        final_author = author or self.author or "User"
        self.author = final_author
        self.last_modified_by = final_author

        # Set realistic dates
        if realistic_dates:
            now = datetime.now(tz=timezone.utc)
            # Created time: 1-7 days ago
            days_ago = random.randint(1, 7)
            hours_offset = random.randint(8, 22)  # Working hours
            created_time = now.replace(
                hour=hours_offset,
                minute=random.randint(0, 59),
                second=random.randint(0, 59),
            )
            created_time = created_time.replace(day=max(1, created_time.day - days_ago))
            self.created = created_time

            # Modified time: after creation
            edit_minutes = editing_time_minutes or random.randint(30, 180)
            from datetime import timedelta

            modified_time = created_time + timedelta(minutes=edit_minutes)
            if modified_time > now:
                modified_time = now
            self.modified = modified_time

        # Set realistic revision count (2-15)
        self.revision = random.randint(2, 15)

        # Set Word as the application
        self.set_application("Microsoft Office Word", "16.0000")

        # Clear programmatic markers
        self._core.category = ""
        self._core.comments = ""

    @property
    def editing_time(self) -> int | None:
        """Get the total editing time in minutes."""
        if self.created and self.modified:
            delta = self.modified - self.created
            return int(delta.total_seconds() / 60)
        return None

    @editing_time.setter
    def editing_time(self, minutes: int) -> None:
        """Set total editing time by adjusting the modified date.

        Args:
            minutes: Editing time in minutes.
        """
        if self.created:
            from datetime import timedelta

            self.modified = self.created + timedelta(minutes=minutes)

    def to_dict(self) -> dict[str, Any]:
        """Export metadata as a dictionary."""
        return {
            "author": self.author,
            "title": self.title,
            "subject": self.subject,
            "keywords": self.keywords,
            "created": str(self.created) if self.created else None,
            "modified": str(self.modified) if self.modified else None,
            "last_modified_by": self.last_modified_by,
            "revision": self.revision,
        }

author property writable

Get the document author.

created property writable

Get the creation date.

editing_time property writable

Get the total editing time in minutes.

keywords property writable

Get the document keywords.

last_modified_by property writable

Get the last modified by name.

modified property writable

Get the last modified date.

revision property writable

Get the revision number.

subject property writable

Get the document subject.

title property writable

Get the document title.

scrub(author=None, realistic_dates=True, editing_time_minutes=None)

Scrub all metadata to remove programmatic generation fingerprints.

Sets realistic values that make the document appear hand-authored in Microsoft Word.

Parameters:

Name Type Description Default
author str | None

Author name to set. If None, uses existing or "User".

None
realistic_dates bool

Generate realistic creation/modified dates.

True
editing_time_minutes int | None

Total editing time. Random 30-180 if None.

None
Source code in src/papersmith/core/metadata.py
def scrub(
    self,
    author: str | None = None,
    realistic_dates: bool = True,
    editing_time_minutes: int | None = None,
) -> None:
    """Scrub all metadata to remove programmatic generation fingerprints.

    Sets realistic values that make the document appear hand-authored
    in Microsoft Word.

    Args:
        author: Author name to set. If None, uses existing or "User".
        realistic_dates: Generate realistic creation/modified dates.
        editing_time_minutes: Total editing time. Random 30-180 if None.
    """
    # Set author
    final_author = author or self.author or "User"
    self.author = final_author
    self.last_modified_by = final_author

    # Set realistic dates
    if realistic_dates:
        now = datetime.now(tz=timezone.utc)
        # Created time: 1-7 days ago
        days_ago = random.randint(1, 7)
        hours_offset = random.randint(8, 22)  # Working hours
        created_time = now.replace(
            hour=hours_offset,
            minute=random.randint(0, 59),
            second=random.randint(0, 59),
        )
        created_time = created_time.replace(day=max(1, created_time.day - days_ago))
        self.created = created_time

        # Modified time: after creation
        edit_minutes = editing_time_minutes or random.randint(30, 180)
        from datetime import timedelta

        modified_time = created_time + timedelta(minutes=edit_minutes)
        if modified_time > now:
            modified_time = now
        self.modified = modified_time

    # Set realistic revision count (2-15)
    self.revision = random.randint(2, 15)

    # Set Word as the application
    self.set_application("Microsoft Office Word", "16.0000")

    # Clear programmatic markers
    self._core.category = ""
    self._core.comments = ""

set_application(name='Microsoft Office Word', version='16.0000')

Set the application name and version in extended properties.

Note: python-docx doesn't directly expose app.xml properties, so this modifies the XML directly.

Parameters:

Name Type Description Default
name str

Application name.

'Microsoft Office Word'
version str

Application version string.

'16.0000'
Source code in src/papersmith/core/metadata.py
def set_application(
    self, name: str = "Microsoft Office Word", version: str = "16.0000"
) -> None:
    """Set the application name and version in extended properties.

    Note: python-docx doesn't directly expose app.xml properties,
    so this modifies the XML directly.

    Args:
        name: Application name.
        version: Application version string.
    """
    from lxml import etree

    # Access the app.xml part if it exists
    try:
        app_part = self._doc.part.package.part_related_by(
            "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"
        )
        root = app_part._element
        nsmap = root.nsmap

        # Find or create Application element
        ns = nsmap.get(
            None, "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
        )
        app_el = root.find(f"{{{ns}}}Application")
        if app_el is not None:
            app_el.text = name
        else:
            app_el = etree.SubElement(root, f"{{{ns}}}Application")
            app_el.text = name

        ver_el = root.find(f"{{{ns}}}AppVersion")
        if ver_el is not None:
            ver_el.text = version
        else:
            ver_el = etree.SubElement(root, f"{{{ns}}}AppVersion")
            ver_el.text = version
    except Exception:
        # If app.xml doesn't exist or can't be modified, skip silently
        pass

to_dict()

Export metadata as a dictionary.

Source code in src/papersmith/core/metadata.py
def to_dict(self) -> dict[str, Any]:
    """Export metadata as a dictionary."""
    return {
        "author": self.author,
        "title": self.title,
        "subject": self.subject,
        "keywords": self.keywords,
        "created": str(self.created) if self.created else None,
        "modified": str(self.modified) if self.modified else None,
        "last_modified_by": self.last_modified_by,
        "revision": self.revision,
    }

Style Presets

get_preset

papersmith.core.styles.get_preset(name)

Get a style preset by name or from a dictionary.

Parameters:

Name Type Description Default
name str | dict[str, Any]

Preset name (e.g., "academic") or a dict of preset values.

required

Returns:

Type Description
StylePreset

The resolved StylePreset.

Raises:

Type Description
ValueError

If the preset name is not found.

Source code in src/papersmith/core/styles.py
def get_preset(name: str | dict[str, Any]) -> StylePreset:
    """Get a style preset by name or from a dictionary.

    Args:
        name: Preset name (e.g., "academic") or a dict of preset values.

    Returns:
        The resolved StylePreset.

    Raises:
        ValueError: If the preset name is not found.
    """
    if isinstance(name, dict):
        return _preset_from_dict(name)
    preset = PRESETS.get(name.lower())
    if preset is None:
        available = ", ".join(sorted(PRESETS.keys()))
        msg = f"Unknown preset '{name}'. Available: {available}"
        raise ValueError(msg)
    return deepcopy(preset)

list_presets

papersmith.core.styles.list_presets()

Return a list of available preset names.

Source code in src/papersmith/core/styles.py
def list_presets() -> list[str]:
    """Return a list of available preset names."""
    return sorted(PRESETS.keys())