Skip to content

Commit fa445ed

Browse files
committed
Preserve styles
1 parent 199da90 commit fa445ed

9 files changed

Lines changed: 102 additions & 17 deletions

File tree

changes/perserve-styles.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Optionally preserve styles with the same id of appended documents. [buchi]

docxcompose/command.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ def setup_parser():
2727
help="path to the output file",
2828
metavar="file",
2929
)
30+
parser.add_argument(
31+
"--preserve-styles",
32+
action="store_true",
33+
default=False,
34+
)
3035
return parser
3136

3237

@@ -46,7 +51,10 @@ def parse_args(parser, args):
4651

4752

4853
def compose_files(parser, parsed_args):
49-
composer = Composer(Document(parsed_args.master))
54+
options = {
55+
"preserve_styles": parsed_args.preserve_styles,
56+
}
57+
composer = Composer(Document(parsed_args.master), **options)
5058
for slave_path in parsed_args.files:
5159
composer.append(Document(slave_path))
5260

docxcompose/composer.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from docxcompose.image import ImageWrapper
1717
from docxcompose.properties import CustomProperties
18+
from docxcompose.utils import increment_name
1819
from docxcompose.utils import NS
1920
from docxcompose.utils import xpath
2021

@@ -36,11 +37,12 @@
3637

3738

3839
class Composer(object):
39-
def __init__(self, doc):
40+
def __init__(self, doc, preserve_styles=False):
4041
self.doc = doc
4142
self.pkg = doc.part.package
4243

4344
self.restart_numbering = True
45+
self.preserve_styles = preserve_styles
4446

4547
self.reset_reference_mapping()
4648

@@ -59,6 +61,7 @@ def append(self, doc, remove_property_fields=True):
5961
def insert(self, index, doc, remove_property_fields=True):
6062
"""Insert the given document at the given index."""
6163
self.reset_reference_mapping()
64+
self._preserved_styles = {}
6265

6366
# Remove custom property fields but keep the values
6467
if remove_property_fields:
@@ -299,24 +302,36 @@ def add_styles(self, doc, element):
299302

300303
for style_id in used_style_ids:
301304
our_style_id = self.mapped_style_id(style_id)
302-
if our_style_id not in our_style_ids:
305+
# To preserve styles with the same id from added documents, we
306+
# create a copy and append a suffix to the id and name.
307+
if self.preserve_styles and our_style_id in our_style_ids:
308+
if our_style_id not in self._preserved_styles:
309+
style_element = deepcopy(doc.styles.element.get_by_id(style_id))
310+
our_style_element = self.doc.styles.element.get_by_id(our_style_id)
311+
if style_element.xml != our_style_element.xml:
312+
new_id = increment_name(our_style_id)
313+
new_name = None
314+
if style_element.name is not None:
315+
new_name = increment_name(style_element.name.val)
316+
while new_id in our_style_ids:
317+
new_id = increment_name(new_id)
318+
if new_name is not None:
319+
new_name = increment_name(new_name)
320+
style_element.styleId = new_id
321+
if new_name is not None:
322+
style_element.name.val = new_name
323+
self.doc.styles.element.append(style_element)
324+
self.add_numberings(doc, style_element)
325+
self.add_linked_styles(doc, style_element)
326+
self._preserved_styles[our_style_id] = style_element.styleId
327+
for el in xpath(element, ".//w:tblStyle|.//w:pStyle|.//w:rStyle"):
328+
el.val = self._preserved_styles[our_style_id]
329+
elif our_style_id not in our_style_ids:
303330
style_element = deepcopy(doc.styles.element.get_by_id(style_id))
304331
if style_element is not None:
305332
self.doc.styles.element.append(style_element)
306333
self.add_numberings(doc, style_element)
307-
# Also add linked styles
308-
linked_style_ids = xpath(style_element, ".//w:link/@w:val")
309-
if linked_style_ids:
310-
linked_style_id = linked_style_ids[0]
311-
our_linked_style_id = self.mapped_style_id(linked_style_id)
312-
if our_linked_style_id not in our_style_ids:
313-
our_linked_style = doc.styles.element.get_by_id(
314-
linked_style_id
315-
)
316-
if our_linked_style is not None:
317-
self.doc.styles.element.append(
318-
deepcopy(our_linked_style)
319-
)
334+
self.add_linked_styles(doc, style_element)
320335
else:
321336
# Create a mapping for abstractNumIds used in existing styles
322337
# This is used when adding numberings to avoid having multiple
@@ -360,6 +375,17 @@ def add_styles(self, doc, element):
360375
# Update our style ids
361376
our_style_ids = [s.style_id for s in self.doc.styles]
362377

378+
def add_linked_styles(self, doc, element):
379+
linked_style_ids = xpath(element, ".//w:link/@w:val")
380+
if linked_style_ids:
381+
linked_style_id = linked_style_ids[0]
382+
our_linked_style_id = self.mapped_style_id(linked_style_id)
383+
our_style_ids = [s.style_id for s in self.doc.styles]
384+
if our_linked_style_id not in our_style_ids:
385+
our_linked_style = doc.styles.element.get_by_id(linked_style_id)
386+
if our_linked_style is not None:
387+
self.doc.styles.element.append(deepcopy(our_linked_style))
388+
363389
def add_numberings(self, doc, element):
364390
"""Add numberings from the given document used in the given element."""
365391
# Search for numbering references

docxcompose/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from docx import Document
1111

1212
from docxcompose.composer import Composer
13+
from docxcompose.utils import to_bool
1314

1415

1516
CHUNK_SIZE = 65536
@@ -48,7 +49,7 @@ async def compose(request):
4849
composed_filename = os.path.join(temp_dir, "composed.docx")
4950

5051
try:
51-
composer = Composer(Document(documents.pop(0)))
52+
composer = Composer(Document(documents.pop(0)), **compose_options(request))
5253
for document in documents:
5354
composer.append(Document(document))
5455
composer.save(composed_filename)
@@ -63,6 +64,12 @@ async def compose(request):
6364
)
6465

6566

67+
def compose_options(request):
68+
return {
69+
"preserve_styles": to_bool(request.rel_url.query.get("preserve_styles", "")),
70+
}
71+
72+
6673
async def save_part_to_file(part, directory):
6774
filename = os.path.join(directory, f"{part.name}_{part.filename}")
6875
with open(filename, "wb") as file_:

docxcompose/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,16 @@ def word_to_python_date_format(format_str):
4848
for word_format, python_format in date_format_map:
4949
format_str = re.sub(word_format, python_format, format_str)
5050
return format_str
51+
52+
53+
def increment_name(name):
54+
increment_part = name.split("_")[-1]
55+
try:
56+
increment = int(increment_part)
57+
except ValueError:
58+
return f"{name}_1"
59+
return f"{name.removesuffix(increment_part)}{increment + 1}"
60+
61+
62+
def to_bool(value):
63+
return value.lower() in ["1", "yes", "true", "on", "ok"]

tests/docs/styles_preserve1.docx

15.9 KB
Binary file not shown.

tests/docs/styles_preserve2.docx

12.4 KB
Binary file not shown.

tests/test_server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ async def test_post_returns_500_if_compose_fails(http_client):
6262
assert text == "Failed composing documents"
6363

6464

65+
async def test_post_with_url_parameters(http_client):
66+
files = {
67+
"master": open(docx_path("master.docx"), "rb"),
68+
"table": open(docx_path("table.docx"), "rb"),
69+
}
70+
resp = await http_client.post("/?preserve_styles=1", data=files)
71+
assert resp.status == 200
72+
composed_doc = ComparableDocument(Document(BytesIO(await resp.read())))
73+
composed_fixture = FixtureDocument("table.docx")
74+
assert composed_doc == composed_fixture
75+
76+
6577
async def test_healtcheck_returns_200(http_client):
6678
resp = await http_client.get("/healthcheck")
6779
assert resp.status == 200

tests/test_styles.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ def test_continue_when_no_styles():
6464
ComposedDocument("aatmay.docx", "aatmay.docx")
6565

6666

67+
def test_preserve_styles_with_same_id():
68+
composer = Composer(
69+
Document(docx_path("styles_preserve1.docx")), preserve_styles=True
70+
)
71+
composer.append(Document(docx_path("styles_preserve2.docx")))
72+
style_ids = [s.style_id for s in composer.doc.styles]
73+
assert "MyCustomStyle" in style_ids
74+
assert "MyCustomStyle_1" in style_ids
75+
76+
77+
def test_ignore_styles_with_same_id():
78+
composer = Composer(Document(docx_path("styles_preserve1.docx")))
79+
composer.append(Document(docx_path("styles_preserve2.docx")))
80+
style_ids = [s.style_id for s in composer.doc.styles]
81+
assert "MyCustomStyle" in style_ids
82+
assert "MyCustomStyle_1" not in style_ids
83+
84+
6785
@pytest.fixture
6886
def merged_styles():
6987
composer = Composer(Document(docx_path("styles_en.docx")))

0 commit comments

Comments
 (0)