Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/docx/oxml/text/checkbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# pyright: reportPrivateUsage=false

"""Low-level elements for inline Word check box content controls."""

from __future__ import annotations

from lxml.etree import _Element

from docx.oxml.ns import qn
from docx.oxml.parser import OxmlElement


def new_checkbox(checked: bool) -> _Element:
"""Return an inline content control with a clickable Word check box."""
sdt = OxmlElement("w:sdt")
properties = OxmlElement("w:sdtPr")
checkbox = OxmlElement("w14:checkbox")
state = OxmlElement("w14:checked")
state.set(qn("w14:val"), "1" if checked else "0")
checkbox.append(state)
for name, value in (("checkedState", "2612"), ("uncheckedState", "2610")):
state_definition = OxmlElement(f"w14:{name}")
state_definition.set(qn("w14:val"), value)
state_definition.set(qn("w14:font"), "MS Gothic")
checkbox.append(state_definition)
properties.append(checkbox)
sdt.append(properties)
content = OxmlElement("w:sdtContent")
run = OxmlElement("w:r")
run_properties = OxmlElement("w:rPr")
fonts = OxmlElement("w:rFonts")
fonts.set(qn("w:ascii"), "MS Gothic")
fonts.set(qn("w:hAnsi"), "MS Gothic")
run_properties.append(fonts)
run.append(run_properties)
glyph = OxmlElement("w:t")
glyph.text = "\u2612" if checked else "\u2610"
run.append(glyph)
content.append(run)
sdt.append(content)
return sdt


def checkbox_checked(sdt: _Element) -> bool:
"""Read the checked state from an inline check box content control."""
state = sdt.find("./" + qn("w:sdtPr") + "/" + qn("w14:checkbox") + "/" + qn("w14:checked"))
return state is not None and state.get(qn("w14:val")) in ("1", "true", "on")


def set_checkbox_checked(sdt: _Element, checked: bool) -> None:
"""Update the content control state and its visible glyph together."""
state = sdt.find("./" + qn("w:sdtPr") + "/" + qn("w14:checkbox") + "/" + qn("w14:checked"))
if state is None:
raise ValueError("content control is not a check box")
state.set(qn("w14:val"), "1" if checked else "0")
glyph = sdt.find("./" + qn("w:sdtContent") + "/" + qn("w:r") + "/" + qn("w:t"))
if glyph is not None:
glyph.text = "\u2612" if checked else "\u2610"
29 changes: 29 additions & 0 deletions src/docx/text/checkbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# pyright: reportPrivateUsage=false

"""Public proxy for a clickable Word check box content control."""

from __future__ import annotations

from lxml.etree import _Element

from docx.oxml.text.checkbox import checkbox_checked, set_checkbox_checked
from docx.shared import StoryChild


class CheckBox(StoryChild):
"""A clickable check box inside a paragraph."""

def __init__(self, sdt: _Element, parent: StoryChild):
super().__init__(parent)
self._element = sdt

@property
def checked(self) -> bool:
"""Whether this check box is checked."""
return checkbox_checked(self._element)

@checked.setter
def checked(self, value: bool) -> None:
if not isinstance(value, bool): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError("checked must be a bool")
set_checkbox_checked(self._element, value)
19 changes: 19 additions & 0 deletions src/docx/text/paragraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from typing import TYPE_CHECKING, Iterator, List, cast

from docx.enum.style import WD_STYLE_TYPE
from docx.oxml.text.checkbox import new_checkbox
from docx.oxml.text.run import CT_R
from docx.shared import StoryChild
from docx.styles.style import ParagraphStyle
from docx.text.checkbox import CheckBox
from docx.text.hyperlink import Hyperlink
from docx.text.pagebreak import RenderedPageBreak
from docx.text.parfmt import ParagraphFormat
Expand All @@ -27,6 +29,23 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart):
super(Paragraph, self).__init__(parent)
self._p = self._element = p

def add_checkbox(self, checked: bool = False) -> CheckBox:
"""Append a clickable Word check box and return its proxy.

The control is placed at the end of this paragraph. Use ``checked=True``
to create it in the checked state.
"""
if not isinstance(checked, bool): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError("checked must be a bool")
sdt = new_checkbox(checked)
self._p.append(sdt)
return CheckBox(sdt, self)

@property
def checkboxes(self) -> List[CheckBox]:
"""Clickable check boxes directly inside this paragraph."""
return [CheckBox(sdt, self) for sdt in self._p.xpath("./w:sdt[w:sdtPr/w14:checkbox]")]

def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run:
"""Append run containing `text` and having character-style `style`.

Expand Down
64 changes: 64 additions & 0 deletions tests/text/test_checkbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# pyright: reportPrivateUsage=false

"""Saved-document tests for clickable paragraph check boxes."""

from __future__ import annotations

from io import BytesIO
from zipfile import ZipFile

import pytest

from docx import Document
from docx.oxml.ns import qn
from docx.text.checkbox import CheckBox


@pytest.mark.parametrize(("checked", "glyph"), [(False, "\u2610"), (True, "\u2612")])
def it_round_trips_a_checkbox(checked: bool, glyph: str) -> None:
document = Document()
paragraph = document.add_paragraph()
checkbox = paragraph.add_checkbox(checked)
paragraph.add_run(" task")
assert isinstance(checkbox, CheckBox)
assert checkbox.checked is checked

stream = BytesIO()
document.save(stream)
with ZipFile(stream) as package:
xml = package.read("word/document.xml")
assert b"w14:checkbox" in xml
stream.seek(0)
reopened = Document(stream)
paragraph = reopened.paragraphs[0]
checkbox = paragraph.checkboxes[0]
assert checkbox.checked is checked
assert paragraph._p.xpath("./w:sdt/w:sdtContent/w:r/w:t")[0].text == glyph

checkbox.checked = not checked
assert checkbox.checked is not checked
assert paragraph._p.xpath("./w:sdt/w:sdtContent/w:r/w:t")[0].text != glyph
assert paragraph._p.xpath("./w:sdt/w:sdtPr/w14:checkbox/w14:checked")[0].get(qn("w14:val")) == (
"0" if checked else "1"
)


def it_accepts_only_boolean_state() -> None:
paragraph = Document().add_paragraph()
with pytest.raises(TypeError, match="checked must be a bool"):
paragraph.add_checkbox(1) # type: ignore[arg-type]
assert paragraph.checkboxes == []
checkbox = paragraph.add_checkbox()
with pytest.raises(TypeError, match="checked must be a bool"):
checkbox.checked = "yes" # type: ignore[assignment]
assert checkbox.checked is False


def it_works_in_table_cells() -> None:
document = Document()
paragraph = document.add_table(1, 1).cell(0, 0).paragraphs[0]
paragraph.add_checkbox(True)
stream = BytesIO()
document.save(stream)
stream.seek(0)
assert Document(stream).tables[0].cell(0, 0).paragraphs[0].checkboxes[0].checked is True