From 77b7ff21c2885f5d41a2d185697a5294e12e4383 Mon Sep 17 00:00:00 2001 From: Thomas Buchberger Date: Mon, 23 Mar 2026 07:54:01 +0100 Subject: [PATCH 1/3] Preserve styles --- changes/perserve-styles.feature | 1 + docxcompose/command.py | 10 +++- docxcompose/composer.py | 56 +++++++++++++----- docxcompose/server.py | 9 ++- docxcompose/utils.py | 13 ++++ .../composed_fixture/styles_preserve.docx | Bin 0 -> 14290 bytes tests/docs/styles_preserve1.docx | Bin 0 -> 16257 bytes tests/docs/styles_preserve2.docx | Bin 0 -> 12688 bytes tests/test_server.py | 12 ++++ tests/test_styles.py | 23 +++++++ 10 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 changes/perserve-styles.feature create mode 100644 tests/docs/composed_fixture/styles_preserve.docx create mode 100644 tests/docs/styles_preserve1.docx create mode 100644 tests/docs/styles_preserve2.docx diff --git a/changes/perserve-styles.feature b/changes/perserve-styles.feature new file mode 100644 index 0000000..9f15f5d --- /dev/null +++ b/changes/perserve-styles.feature @@ -0,0 +1 @@ +Optionally preserve styles with the same id of appended documents. [buchi] \ No newline at end of file diff --git a/docxcompose/command.py b/docxcompose/command.py index 0b2e253..be50f73 100644 --- a/docxcompose/command.py +++ b/docxcompose/command.py @@ -27,6 +27,11 @@ def setup_parser(): help="path to the output file", metavar="file", ) + parser.add_argument( + "--preserve-styles", + action="store_true", + default=False, + ) return parser @@ -46,7 +51,10 @@ def parse_args(parser, args): def compose_files(parser, parsed_args): - composer = Composer(Document(parsed_args.master)) + options = { + "preserve_styles": parsed_args.preserve_styles, + } + composer = Composer(Document(parsed_args.master), **options) for slave_path in parsed_args.files: composer.append(Document(slave_path)) diff --git a/docxcompose/composer.py b/docxcompose/composer.py index 05a1088..554ea58 100644 --- a/docxcompose/composer.py +++ b/docxcompose/composer.py @@ -15,6 +15,7 @@ from docxcompose.image import ImageWrapper from docxcompose.properties import CustomProperties +from docxcompose.utils import increment_name from docxcompose.utils import NS from docxcompose.utils import xpath @@ -36,11 +37,12 @@ class Composer(object): - def __init__(self, doc): + def __init__(self, doc, preserve_styles=False): self.doc = doc self.pkg = doc.part.package self.restart_numbering = True + self.preserve_styles = preserve_styles self.reset_reference_mapping() @@ -59,6 +61,7 @@ def append(self, doc, remove_property_fields=True): def insert(self, index, doc, remove_property_fields=True): """Insert the given document at the given index.""" self.reset_reference_mapping() + self._preserved_styles = {} # Remove custom property fields but keep the values if remove_property_fields: @@ -299,24 +302,36 @@ def add_styles(self, doc, element): for style_id in used_style_ids: our_style_id = self.mapped_style_id(style_id) - if our_style_id not in our_style_ids: + # To preserve styles with the same id from added documents, we + # create a copy and append a suffix to the id and name. + if self.preserve_styles and our_style_id in our_style_ids: + if our_style_id not in self._preserved_styles: + style_element = deepcopy(doc.styles.element.get_by_id(style_id)) + our_style_element = self.doc.styles.element.get_by_id(our_style_id) + if style_element.xml != our_style_element.xml: + new_id = increment_name(our_style_id) + new_name = None + if style_element.name is not None: + new_name = increment_name(style_element.name.val) + while new_id in our_style_ids: + new_id = increment_name(new_id) + if new_name is not None: + new_name = increment_name(new_name) + style_element.styleId = new_id + if new_name is not None: + style_element.name.val = new_name + self.doc.styles.element.append(style_element) + self.add_numberings(doc, style_element) + self.add_linked_styles(doc, style_element) + self._preserved_styles[our_style_id] = style_element.styleId + for el in xpath(element, ".//w:tblStyle|.//w:pStyle|.//w:rStyle"): + el.val = self._preserved_styles[our_style_id] + elif our_style_id not in our_style_ids: style_element = deepcopy(doc.styles.element.get_by_id(style_id)) if style_element is not None: self.doc.styles.element.append(style_element) self.add_numberings(doc, style_element) - # Also add linked styles - linked_style_ids = xpath(style_element, ".//w:link/@w:val") - if linked_style_ids: - linked_style_id = linked_style_ids[0] - our_linked_style_id = self.mapped_style_id(linked_style_id) - if our_linked_style_id not in our_style_ids: - our_linked_style = doc.styles.element.get_by_id( - linked_style_id - ) - if our_linked_style is not None: - self.doc.styles.element.append( - deepcopy(our_linked_style) - ) + self.add_linked_styles(doc, style_element) else: # Create a mapping for abstractNumIds used in existing styles # This is used when adding numberings to avoid having multiple @@ -360,6 +375,17 @@ def add_styles(self, doc, element): # Update our style ids our_style_ids = [s.style_id for s in self.doc.styles] + def add_linked_styles(self, doc, element): + linked_style_ids = xpath(element, ".//w:link/@w:val") + if linked_style_ids: + linked_style_id = linked_style_ids[0] + our_linked_style_id = self.mapped_style_id(linked_style_id) + our_style_ids = [s.style_id for s in self.doc.styles] + if our_linked_style_id not in our_style_ids: + our_linked_style = doc.styles.element.get_by_id(linked_style_id) + if our_linked_style is not None: + self.doc.styles.element.append(deepcopy(our_linked_style)) + def add_numberings(self, doc, element): """Add numberings from the given document used in the given element.""" # Search for numbering references diff --git a/docxcompose/server.py b/docxcompose/server.py index 4a0a8cb..36885f4 100644 --- a/docxcompose/server.py +++ b/docxcompose/server.py @@ -10,6 +10,7 @@ from docx import Document from docxcompose.composer import Composer +from docxcompose.utils import to_bool CHUNK_SIZE = 65536 @@ -48,7 +49,7 @@ async def compose(request): composed_filename = os.path.join(temp_dir, "composed.docx") try: - composer = Composer(Document(documents.pop(0))) + composer = Composer(Document(documents.pop(0)), **compose_options(request)) for document in documents: composer.append(Document(document)) composer.save(composed_filename) @@ -63,6 +64,12 @@ async def compose(request): ) +def compose_options(request): + return { + "preserve_styles": to_bool(request.rel_url.query.get("preserve_styles", "")), + } + + async def save_part_to_file(part, directory): filename = os.path.join(directory, f"{part.name}_{part.filename}") with open(filename, "wb") as file_: diff --git a/docxcompose/utils.py b/docxcompose/utils.py index 36be83a..e0982d0 100644 --- a/docxcompose/utils.py +++ b/docxcompose/utils.py @@ -48,3 +48,16 @@ def word_to_python_date_format(format_str): for word_format, python_format in date_format_map: format_str = re.sub(word_format, python_format, format_str) return format_str + + +def increment_name(name): + increment_part = name.split("_")[-1] + try: + increment = int(increment_part) + except ValueError: + return f"{name}_1" + return f"{name.removesuffix(increment_part)}{increment + 1}" + + +def to_bool(value): + return value.lower() in ["1", "yes", "true", "on", "ok"] diff --git a/tests/docs/composed_fixture/styles_preserve.docx b/tests/docs/composed_fixture/styles_preserve.docx new file mode 100644 index 0000000000000000000000000000000000000000..4845f7f4de27ebb050f19f5d4d8c811b8e07beb7 GIT binary patch literal 14290 zcmajG1$Z2}5-w_HW@cuNF=nPQGcz;C7_%KSGsn!#j4?yZ%*>7<_I0x7oZZcP@4lID zs(Up0OWm!1R8px5APowJ1_T5I1vI4Yt<84{#_kLZ1XKuh4{tf%Z@ zZ{noG;BI5xkT`DpnF%TI!W*VPNElqsv$&LoEaVk$MM9RayC%7xW-+Pb(d(IkET`0R zryd%UY;h?y8o0;2wqn#Aeu6gt#yAB&4yv9>@klhQeKKpdjqG~3VYhHPhLY!U#P}0? z+8N!C(4>aR9rC;mvu_dP%ByCB0Y#Dq+5@q2YXKSzAAf)>j_ccx1x`cZD3$YVUmYUD zbkZ<;F3;>ikhub$I6W6(Ps(}s7m}px3%Inb9_h*X$c*ck%wH5Wd}RYtex?BAeGoZM zI}};vBtwf~U!x5D5w9vVLTIoAwFK zt@{Yvt1mC=ydVhGQM$iresYbJ>4MY?N^;~$mFI#$x>gIht5IF*5v;6>;NIpv*{q z7av83$-kak(}qgabx7oXhT=Mcb*2bSwDRwoL#FE2{V*vGdsk5L0%%mQ4 zvNCAL@`QEbm6N;zLO0={J*74mL~o|Uwu5LqWhU!OTGaPnMZQ-XZSV+3Pvh}|f1Cw5 z97dyC5*=;$SeLJOl>%u@;ia2<$?zD&+`yI>kq;NjuKqRGH3I7GHyrZ(Kj6w(0&IO9G)sdyYRUA{i{GxB}B zp5ESXnAV8+NM)sJ%{`(KTvVUP@{I09bzWJifkBc2Q_tRPC=Pcj=JP_-xXyj6U@#2T z8;H)+f0&dJa_t8n2wezoVtIlay8Fb}-EwYe&Cue4bnU@?CUJOq7GJ%);#fDv;%xkAM1`%(#(^wpzE%-Uj zG~$hj2S5Idvl_X>?b`(3tB}shpg-3r4^ef�TN!R5_%nB?ND2!=O{En|5Lc`FY7N zdRsl{_Z&Kt7L&<6(#+-9 zLL0nzrVYiE19Xp51Iw>EiJwOB7zG|Ha_Hd>So`*wkZr+F+^Fm62fcwBhN7KPN> z-{-KQ^12A~+v9JF00wX7eyUG&)SQ@q<{AjvjA>;zcS~C%7a0_xa#s=xV1cVQ64V|N zDf;|{nMF(@9zH?+d7-DG8Kbm+Fo{MSC7yl)SX7Wnh#X6%rl%O#vz8JeNhtfPvbt~+Aj{_*|T$-Z6oQO-+?3F!{JY9nv_cT&b@nN^FaD7Gcrq|tAQQ;d* zFNT7v4UAi4QUbEf_H23rHf#-`>J}?L1-Ba*O{KNVkmSPYBuLR@bfahOJHj`Ge8{Pkf903T${etjbsK9`@h8(na+_ff5`D z=os^_UU9Q?G-iDN%evUSTbf_Ja;B~oz08T`v!Q2j#looMNDf^rBMMn8wycqxdDdAO zY1CcHSaK$?q}Meh*$h9ckfYd8r>QG6vGwWW>%{o}&k(zSeD?WZYOZ$Z)>rtvxZ;eF zz03U@!dbT^covK?JSXy_OH=9b@sKvXbvuX_lER!B5`h@gYU<(Yy4pr8_bg&sn(8AM zv3eFugEkRpd3uaUfL*@xd63Vh+6GJo6gxyx+mPcI*{}Yj8_T(SICkBOz=H%D1~x}c zpknsS5|bxhOI!eVaHLZvxQX1nh+LFO4;KhDJ#aAHOyavrVNV065f_OqE=5m#Mdzpl zoe<|FxozGa6+v<;Oet9FEfq#S|GVT`cgSw=M(YXAI<4;8czih1>oIf!|9JvIk;eh3 z5=vz`kAw@rT(y!Vm-*?&*;(ev=jBe1Q#$ALpWi8Vinx?{9@zG}gW^mF!o7nctfmE8 zmi^21R{AwBCpvk+>BsiG&MdT*JbSA$%f2IYT={aWUn-uX>OW!6L%y ziH;+;2#!J2fIN{Xsx|OQXT~y)&$b%3L-P$Eje;&E%W64uY6ykBIO`j}v|E4nxz4!jeMw=Jr_~l{ zLN&guPLSH+f{f1LMKb0rCGk5(b3#-$qhB7tX(5lXpKVBn$ELwoB%zJ7P}(Dhx`gGn?AV$Wj@Z$ zj+D#4gaey{N-E98D2qA139XczR)tiAp2gY>sZN%V#R`_ol*AR3TN?!mptQKzLy89n z0}+6(EeuW&aE2~32&qJmFZN@_Xz1=zu0=D9fJvL>I1SNA@QK`hZu<60%D`UsLouzI z0S7oy9V7zGLCr$vmIx7iR@6wPgy|4(3ErqVki}9|gY)g0<8Sf=K}W_Y_DumsDqrr+T7&oi4e8dZ7ZG4-SPJ^ z@D>lbhBBVs6i zk1@PrgibD-TkCo0FtnU1Pr9? zhTBuC<5}k&B$u~RBg><{Et7y=FNl}L*(3_uQ6Ebn&=N0|uA7G|I}WMwX&C1RP1N^Q zIzDsHeaZX698*18s*yq9%BuyHd5p{Q2w_ARMiG1GwoSOg5@2w?s4hbkF4j>-*3}^6 zYQMwL2FgguD`MVYk>dJZnf6kBgip~s5-`v(AZT7-Ux&9J|rpRg#FxP zV8x0S+Ko)q9|J?G-2`_CgWv0~h6MfycsbjsQx8Rj;(vCTA^f+QDwwhf%6(r4^$`I9 zq5t=RI-8r=m@xj9%)d>Q#<<-I2U-{6;v3Q7`Eg$ye*kw7{fg{L0X$BVaCE(lbR_jO zQ3;utg1)(Y8ywaYJWqMIEe~8DOnG;>&ixRWd5w#2Kh+3fo@|3;Is!NbWVPt^1A*_S zi>s@kR?joC-eu$hxY*`GEj1aP&o%O5R0x_+9fDmmQ|yC+ydWw4)rFHpvsE^}uS+V` z1HxE>vcp5xu=TewH%CbZOnT0su0b*ebc2I|Vtx@6@R7*iLqc|e@GERqVqk!BE9TI^ zWu6`8>)g%`z)IgX?LOVvB{Sb;T?7)Z@Wk%(f7%ZPAx6n=7wgDY*-B7_(Lw1XN5jI-@C$2=DiwpZAutHfi^H{ z8_>8<8V|nJfCFBOr`9Um%8~*Z536i9S8Sgsx5{YS>-;YsIuhYwb6%#`y4x~MztWz# zsr^`n=(KHYlhf%n%R$O_ul46x)122TcZJ4AZO;$EIH5luAU6f$*kQJ))a9 z*Bf{^1C}$K{bPDB^CkEMC>A{RjH?1tcz1e03e&XBvgt;7_{HtyNESm(l~^Lf}9HAcOxk3J4hKOSZ&<>d(9xU;>KY$5vv70$=yH(qW|rOz%l zd_LXWYO!abUsd@&9^VoAcFOW4n|nXJ+(jHzb-q1SWoOfD+Q0;h{s0nA(2okJ_JOT7@tq2iaaDquv#`< zY-x~@Qj4RaJ7OX!inxyS!wRygIEp!__pj@tBIgOLP-;sC4D*oL!1DVw&#gTm?;fnI zJc!^ZLP~JQ0p^qZMAoBOc_9FW?XslQ1C7**V&at01yk!mNS}?E0sw7p!aD*M#DKt=pSH)NGO6%f#D>evpbeFw^RK&LF#30&cg6m_#Ea$N8JZr zMWhNti_%qPw-F+`Q|r$|1YpYQ$LFrN^RGM}QewD1A!57RV#h8{TBied1MrzZiAv#A zTG2_uG|ATFQq}VkaL3YJ_aKZa4#NO16ENl#_YVBJXQVWdYW%#<=64eVYTPCx#vPs1 zeHCi#1i7TQN0{t7G5ybg4ANxwaTD1a-*Su5`x_H-CyfzpRhBmm@;Vyb$Zb*iB66CJ zJ8aTO#2;{dI!TFqGN(Tk^qft<$e4SKu)z=PeBy@21jO)JC0sHhAtDMfq0g!>!2ze2 zA*67S%b>}5mYkPgO6BpfjC#>qIaE=D0qNaE^jb|bj}2-NRh(u$NE9 zR?=;(P}iMpVUm|7gV~~prwTNnLWsUQhrM^HD{*b8C{VEb1gD3a-U2-2D9wZ6Re21< z{xPNTw&q8&B(IoKZggQP%qEfT(Z_7uCvTRq1eSGhx^D(cfX-Ja2MP8_666!n51Z}- zWd#7tS`k^~u19Mkf6LIVV zvGg>Z0?mn=)l{{m)ov=NI_xxT6#Tvtr({zN7-#j>9gt`Ca6!X!3sT5mQ&_g5v`E#}Ra;*WkW#5?_@Qks zozNYpT)aY|n6_?VFv5m~7cF)Jecm#FYpiNePUP{7URQEImFPsrI2pB)9IfFtq-e#J zcjb=8wC_wMqtr+h$F@z=qRn>xxFu=nY%w}e!5vG(_M@BLOI@_t&4+u9w?VCEa|KJi zD@o&+as&>Rt<#DQ6WaA|Qp+qA~&B4-Cs%yG@4u8d#=gu|%pq_BthF-Pvhfd0eora%` z|F&t5w3#jTza|Lq|5$oCnK(OJ*qS;0vS$ry3QpS`NIn-;mzTY$Jsgdo=drjTxg)ZN zE>X_hvmcg{YAE9NVvV8PX5ZEmO$;OsW#ioOq)=;y#*)& zHY4*Jdi%RI(0+3ma<1DacTfhz$hf-3xEHnXioqyDKIg*FafQuCR@p-HykUzTk$xjO={aA z;Sp&!_2kY$;T=%ygaZ&BIEL~^C31EmM2!<@eNbIFiG6=Gj@5GYq$V%BL z$14ts?0zWDz~WOAb&CLW(2<`r-lF%{)J&t%Dqp<1{HO}y3mDISw~5rc2wFNtPO90g z53XLq@P51=ovISRqI9$}CAOR$35HW;_RA#di-kNK z_BB;7+8mTmcIGM=h%Zx{7XDyT=olw3g)uf0(&U^CKG<)WWn8!9z(a!~yBTG1v@ao? zuK_^x&Hir3b7|>!@YzG#EM8Yvkq2{@7IGl6-%rJ?X8nl^MBp0t3(6|pgI&n21a+%& zvnqnkB3wC;Ltuqt;P}lY%jdJU$AK-U+Hba z@fqI&4eef~(GD!xxD~Buf46LjXKd$Sv4GszEWp!V8~Fy?r}_!!49V_v!#%<&v1b+ z36)u#4jGJ#_Gbo`WwZ#dZv4zPba2*}=vbFbb$cczKjY9Da_f9>ZKKK1atuzpLFb_U zq8%H)ehpgZbw)^rwUL@1vX`wahjKZicrYj1;DWf4QZDi-4P)FIE5#_bsiegWbyZr^ zb0xHs45~!6MJ=oOV&z3DJ3TLr=&a~g^djv-DkirX*6kM2ak9C*^n&1e*zMfu(Z)MX zDI5BoJ!yyu$BF)KJcrhu4fu~=%5CPBK&)W7waje{%p%I^XE>Uhs{KwbkfW0DExjhF z_joeaTz|mEQwPuif-v`8!8{y{%&Vl2tc_m4+!!Z9kGX0|u!S!#ueG~k9ZzJZv%Vkq zs~Ih~aGOj`CN!qP$p_$u@ux?0HYkBOYF}u~=rm%QZYM~Xi=eNLg~LYtT-Mh`GUM%- zm5<0lpZ~Nix_&s*ZQzt$R;1R`>D%|n9>;0LjEj>tMQDJ;V7g9*N7onj0g8kXCz7&m zpj*fDLvsE!istYh{~2+^n^om2_`ipz>&GxExp!Y(8Z;0P%73Tarte!2$_9qkza4gK zi8^*GL}XIjP5*v<@3>a7ACB2BxuV>lllKu?WJG9<3pRF?6` z{rkA0xAxe5+*ik7&YFR9Rf2brdqQc8oN_s^Uteom(~ZEAN;6b7uiywdH;49~-4Ms~ zrba;=?_e6K=E56_1NlUtiIr9j zzR{!Upaon-r`eFJam1tWr|E=B9E9qI^%+!613$5z1gJ zK_~ISm-OVq3`*&WTBYibU&rbB@?i*F>sto;`Dea%`WeB-8194pIJ}_6n-^E5f}JL& z0Ytaer|)puSE3g?w<^~6XG!YU~==u-50h7ca2PS-zd8L;a}t^F$3YSG;%vhqNBPMZg}lc+vKepUj1 zCMmYg9mf!D#J=A>=>d$o1;LsD1Rz?K7<5c+SNKfrS)efbiF3*q{1t;~09Xu&$?1_b z%0ZLqX|#3}nIMXF#XeANYtf|$B($SnE0-#Nrt0UnBy#J4ZI#4fhs-ovdq%$}2Bk*e$ltT$y`U#7SA z4>*Y%+5^uUk942tx^8}4&C51W9`F$m_-2~v(W5=I59RGVikdDmLM&_$o_oH<+q#cy z=dKux1=6M>ntg^lhD}uV%s%b?$}S9}!_mO}qFm#>=}_pC2)pkgrKt6Io$uV` zWS3P8f55kv^mKi4=1pc?Ff-wCaa#W4-A(5}${QV$h~JlZ`J)LPv1@R3#pmt0K@NWa zvr zY#ioMy6sW(lhmX2#gN0`!K(SG-3FYrPF3N$@0N$}r`+BZ!4t2i!6Jtx{ru&25CB96 zI7<;HI74A4I87lZI7LAxI4J=qI8^*`=5I!9Lrk*B86vGk8tSR1Mt#Y71WWDRq5QeWAi_TN!D9*iX>PX+l*}ce&uBJ#`^(4t8VbY;1 zjt^8QPz0GuK=;Ms^v8RLulvN2-5yjR92k@~4n)f3*_34JTE9c!%yzI%rJN`F4>~i# z(J<#bDhQHZD+`i3s=pt#KJu5ms7{6c=hOOpCP?;iL(c_nCA+Qd+C(Pya(|oI*b;c^ zr@c}JtIN4YKLKf@F4vOA(PvtEXP)F4F9!muC<|Pa`Bq)|SXy>Vb(yf!#6+o4uXz?RC1ky~O z`Ax=_aPtb+8~8ckf1=Y3R3&UF3B4htFAF4!!7?3;{}sw8<|$h0PPGG;f6i)T>A zeml@qxz1Ox8tFn@pv8#5B3dRq9$P`q;AuSP&GE_(i=nW+hz6zj6k-eRaAW^4?GbZQ z{_Pu{Flc*%Bnc>ssRPAj)y9u)J2w1wfT;ZsA+}}Myjfu!S8^x>7A{Fo5weDFXt_jO z!ItZgueC9B*XHIA$dea9Qk=*R69EnKh4hJ-*~+iP>hKop{OV-2H$uvJo<#mCa=3#> z+j^^KS;rqGLw3JS9k5Lw5Z);^JYd@IuB2+%32#PD=6VyIRGcCRs^_*`OLwN1g zabgTg7ccOZZYA*^U%4{A!erf=)XR8tLnaBZAijSvrTF1{Qylx##5wiH^9dNY5~g_r zlpNC@9wgk2d#0F5CCsr@u-GDLAaKNR{LbADmG#%`W=pzTH_GDsLX&{dvF3S6`Cd?U z_DZ^o_fvxv##!O>)68(h*e3@s@H(|iMVZ!!ivkMLoY2YE{3&GItCNr;bVFkyDnjccf)Mg&sjj@!BI(R1u#}+Sqo+HQ!2X<&2_RGeXUTI7@IL$RMPI*j2 zaLaEVt>^SzhxlS4OvJbP(CQq#c95B}@Ho~h$z18a($gEH*Yzc>s9ZSwZNPLj_H);> zzcd5&L8CA41j3xz1j0~r2nC`2RsN1ZC;6)y{r7t@C)!*g7|miNe+(LtoFMKaDrNI% zRGRuGWSW9SsDJAFA1(pl-;xL*2LIjoFIAbYXf*Xh?~Sdz1FYX0GtU(Q`=3e}N< zin~{(iemzPxALoKf*a=l=jw<-$d&bN$(1Dp|GMDNqDKwwJhnAc{{HMcw)L85_X*02 zx#Z*gRh|RDO8%&Bs>WHiXmi4Ko6_^9nw=8wV(X5W`Gk-d=a%?1DNP~fklxTeNU;=OoDe=(k2t9g;c9%@}4a9u{~I1 z`i#tgyE&8H=mNUa;dT+84s#%Z?dUgj;ls_MPuk2G+!~JSO<9e%v(`?&)I@BuLoi$Y zGUjPXOsa!U&lR6dDeAvurA~1~nu5?|iNSrWHbvv^HBHbd&iz)M+lX13>IctGrXGZ& ze<}>Gu#g)})duM64}CAw7q-dH7zZl@fJS;36OVGwA(>!Wx>NN9 zD}QfLHg~~H;k2wUoXZwjmAM~3hBml)SDU%MIEiK{3V!{ zRiufsT;~;S^v_BQm!Vcvr1t?krG>4471;So%~DwQN==qv95s6O|A|!LFJ(!`LchiP zO`VmJ8x=duEB;T!am_VbmN5K7;3E#dxA1?d&}=z^WvSgT_dj8b@ZRq4#{U+7T*ewT zs^BFm;KolhO!&de;RBs6dHHiRst-IVf%F^X1Kx73kI0wWxB8o>vdD^!@vryt36r&Y z0#&?~yL(+gT0nPEO}h8Eo=ZHhB=TLuG$#>C?I`8$zKQ3K4m})pO6k3WtBIE5^gbP< zQ;P#6rX!O(wd0FAqK2u(v7Z;M}$2)`QHNY{wo}95js%;;q*|IPjpA1+`<|lmTiD(VF0n^^i z0<13KbZ$i0w^6T%RC5JV<&BlIo+juQmp#;Fen0ipio;azmVK})XWw2!Ljpdi}X&_CR{S~qpF5Dr`Je|II=t;x

rRw-k#eYU7(F2{!wM!n%9R9 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 literal 0 HcmV?d00001 diff --git a/tests/docs/styles_preserve1.docx b/tests/docs/styles_preserve1.docx new file mode 100644 index 0000000000000000000000000000000000000000..0329556e2fbbf9623f8907bf53be47dc50ff42ae GIT binary patch literal 16257 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 literal 0 HcmV?d00001 diff --git a/tests/docs/styles_preserve2.docx b/tests/docs/styles_preserve2.docx new file mode 100644 index 0000000000000000000000000000000000000000..6da64a9e418fbd70148ce0ffb24230e4cd7bd396 GIT binary patch literal 12688 zcmeHtWmFx>y7oo_!7aGEyM^Gv-QC?bf#B{M+}+*XAp{8S65QQ_yWTxB=X^7hGwZJP z{l2Gv)ZX1y&)Z$yuT{N|yc7iFI{*~mEdT%@2CU^z0X4w@0HQYl02<&exR#L3CkK;H z4tmP2wkGyE3@+9{qFhLDs%!u_X#9VV|HE&fK7RO9HxshRZNdXmOp}V?er_oZco<(I zz2X5hmOHrWb8LV6%U3jT1r@LuSRe^G>+LG5YM=k)Vk!^@rNNo}2$MI_H%`}rlXhWl zo8H?Pn_?rLX;pBL!w1OL-hnO60meky&=WhV1S^-Gm+p_Qeg}x8Nr+M# z5iDkGIW@OJJ!d8Jl?u8r0<4`!WNQf@i8^K-2f)228-Om&UnP1z+>Vx?yBwN~)664k zwE`1k4w2(}Grl{&nEoYy?DG|vMcx*F-)a@GV$5P4zvjZJjNEKd`&> zXnh9(0KC3J0ObE>k_0h0O{X9|lLc8E9LOZ~>`Z|6j10fE|24(`!}j-=sh7ugS@tl& z3!M5t`%iW#EOue#$ub&EEMYA`ztxhEMq8SX9`X;sq10~K=@l05ni zZHGqqW~T_wf`oc}#)zygFY~j?+-2QEdjO6JthUKt0XYG@RZh4(Wy`h10FgY-xjE^3s+Nhc@ zdtY|3`8iIBHR|;?Y;e=|Am*Q`GZQE+hajhztOrfL@BTjh!*0v5k?VH7IWV z7O-a2rEIo1kiTN?dlNax(hWL2A#c*f%GAD{cqcmjGo!GNrN+bjzIyBB0g-(8rgAFq zI6YQ~YRsGuvC}pB;<`wUdH^SUM{_i{K1mqz-87e4FWtk#?bZ$%$*e+xQV#k^F8%!e z^yq1)j!msLE2AKnAM%iCTI^_Ug1rewe`1Az;!<2Xw)OSYb#h{jE(4dpOP&nDZ5&%k zreq$rdf1U?p{0y2N9zYV?u{bF@1Z)2#exzm(iLBWJ@rWQu4*xmZT*;CDeuz zqnvuWB(@UxdbV9F7|d}B5JD{vPC;Vk4Pw#$PsRbC=^POaqdBSEFkJI$GoE)=`I<9- z+_dG?FzMXrr7fSP3N>8L`v@@9*V5{q&}wIf1r4b{)V}=if z0XTj3?(clpu-7Rn9fyblgX|v0!0^;;x)u7Jj)%?zgI#R95Fb|@V!{S29LoWChtOvn zj8Z2Q2!*#=!-kXofd(v?Fhqv?rY0+c3%t5zSdB903RibaUkYDGp-t|}=WP*}V?*%L z5NYJlrf1+;uelz<#NFdWCZe@98Uq%k{!iG$=7FD&>8=`+sBIa>3raclIdHxN2Cm2> zwu&M|PyNC$Lf3Dr5_rC+1`wdFtdE-KTv}sE%^=jp;yeUMaA8TG1~uh*DNCGde-`|( z`kceoqow{bM<*G3{=+;O{wBA@RmcTN>M+pCYF8|GD6FL<2(t-&-@CEU5QEo--~Fh* zJV3$29{*I2&4vM&?PM3pS^yQesT->eXC1N=DGzhv$8>!94evct&_sIDGRt;6Ak&BU zLfY3iH!*(keLsF`uHc5RemvdJ7%>~{?Acr{?d*E_c=Z8^gWgnH%eN=9wtZc?*&{Ae zkPN~8`QYV6T;zU?-}b{gl$ry+E6JTs$eRs1kJEQ?gh{;|pp#lf&glukB#|el=b=zB zxcefjO&VI-ErQ7q+E6x0JehXx<$$U{X}`~gus!*8XBm_iC3(vM}cy(^wbe%+ES8+R0)`aJRt;=P)tD|J(T z0A6b~zgIG!cEl;T6k+1C*`ZAKcNA-^(-^J%SQ_IP6cm~YvCNqqeYb60HJUH20ymM& zhc)4?3O7txGL+MV)VM3M!OmM7dr^W5VNEXmgDt(f>FⅅN>n*;L*d;(eKhG7sIBw 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 DC_Ae^ literal 0 HcmV?d00001 diff --git a/tests/test_server.py b/tests/test_server.py index 93f8360..91e3fbc 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -62,6 +62,18 @@ async def test_post_returns_500_if_compose_fails(http_client): assert text == "Failed composing documents" +async def test_post_with_url_parameters(http_client): + files = { + "master": open(docx_path("master.docx"), "rb"), + "table": open(docx_path("table.docx"), "rb"), + } + resp = await http_client.post("/?preserve_styles=1", data=files) + assert resp.status == 200 + composed_doc = ComparableDocument(Document(BytesIO(await resp.read()))) + composed_fixture = FixtureDocument("table.docx") + assert composed_doc == composed_fixture + + async def test_healtcheck_returns_200(http_client): resp = await http_client.get("/healthcheck") assert resp.status == 200 diff --git a/tests/test_styles.py b/tests/test_styles.py index 3b0060e..06e32fd 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -1,5 +1,6 @@ import pytest from docx import Document +from utils import ComparableDocument from utils import ComposedDocument from utils import docx_path from utils import FixtureDocument @@ -64,6 +65,28 @@ def test_continue_when_no_styles(): ComposedDocument("aatmay.docx", "aatmay.docx") +def test_preserve_styles_with_same_id(): + composer = Composer( + Document(docx_path("styles_preserve1.docx")), preserve_styles=True + ) + composer.append(Document(docx_path("styles_preserve2.docx"))) + style_ids = [s.style_id for s in composer.doc.styles] + assert "MyCustomStyle" in style_ids + assert "MyCustomStyle_1" in style_ids + + expected = FixtureDocument("styles_preserve.docx") + composed = ComparableDocument(composer.doc) + assert composed == expected + + +def test_ignore_styles_with_same_id(): + composer = Composer(Document(docx_path("styles_preserve1.docx"))) + composer.append(Document(docx_path("styles_preserve2.docx"))) + style_ids = [s.style_id for s in composer.doc.styles] + assert "MyCustomStyle" in style_ids + assert "MyCustomStyle_1" not in style_ids + + @pytest.fixture def merged_styles(): composer = Composer(Document(docx_path("styles_en.docx"))) From 58f4e26829838a00b12e13ce1908019689d864be Mon Sep 17 00:00:00 2001 From: Thomas Buchberger Date: Sun, 29 Mar 2026 12:31:01 +0200 Subject: [PATCH 2/3] More sophisticated style comparison Ignore some tags and ignore order of tags while comparing xml elements. --- docxcompose/composer.py | 14 +++++++- docxcompose/utils.py | 71 +++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 43 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils.py diff --git a/docxcompose/composer.py b/docxcompose/composer.py index 554ea58..63ae23b 100644 --- a/docxcompose/composer.py +++ b/docxcompose/composer.py @@ -17,6 +17,7 @@ from docxcompose.properties import CustomProperties from docxcompose.utils import increment_name from docxcompose.utils import NS +from docxcompose.utils import xml_elements_equal from docxcompose.utils import xpath @@ -35,6 +36,13 @@ RT.FOOTNOTES, ] +IGNORED_STYLE_TAGS = set( + [ + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}name", + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rsid", + ] +) + class Composer(object): def __init__(self, doc, preserve_styles=False): @@ -308,7 +316,11 @@ def add_styles(self, doc, element): if our_style_id not in self._preserved_styles: style_element = deepcopy(doc.styles.element.get_by_id(style_id)) our_style_element = self.doc.styles.element.get_by_id(our_style_id) - if style_element.xml != our_style_element.xml: + if not xml_elements_equal( + style_element, + our_style_element, + ignored_tags=IGNORED_STYLE_TAGS, + ): new_id = increment_name(our_style_id) new_name = None if style_element.name is not None: diff --git a/docxcompose/utils.py b/docxcompose/utils.py index e0982d0..e287968 100644 --- a/docxcompose/utils.py +++ b/docxcompose/utils.py @@ -61,3 +61,74 @@ def increment_name(name): def to_bool(value): return value.lower() in ["1", "yes", "true", "on", "ok"] + + +def xml_elements_equal( + left, + right, + ignored_tags=None, + compare_text=True, + compare_tail=False, + compare_attributes=True, +): + return xml_element_signature( + left, + ignored_tags=ignored_tags, + compare_text=compare_text, + compare_tail=compare_tail, + compare_attributes=compare_attributes, + ) == xml_element_signature( + right, + ignored_tags=ignored_tags, + compare_text=compare_text, + compare_tail=compare_tail, + compare_attributes=compare_attributes, + ) + + +def xml_element_signature( + element, + ignored_tags=None, + compare_text=True, + compare_tail=False, + compare_attributes=True, + is_root=True, +): + """ + Creates a canonical, recursive representation of an element. + + Child elements are included as a sorted list of signatures, + so their order is irrelevant. + """ + tag = element.tag + attrs = tuple(sorted(element.attrib.items())) if compare_attributes else () + text = normalize_text(element.text) if compare_text else None + tail = normalize_text(element.tail) if compare_tail else None + + child_signatures = [] + for child in element: + if ignored_tags and child.tag in ignored_tags: + continue + + child_signatures.append( + xml_element_signature( + child, + ignored_tags=ignored_tags, + compare_text=compare_text, + compare_tail=compare_tail, + compare_attributes=compare_attributes, + is_root=False, + ) + ) + child_signatures.sort() + + if is_root: + return (None, None, None, None, tuple(child_signatures)) + else: + return (tag, attrs, text, tail, tuple(child_signatures)) + + +def normalize_text(value): + if value is None: + return "" + return value.strip() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..8509578 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,43 @@ +from lxml import etree + +from docxcompose.utils import xml_elements_equal + + +def test_xml_elements_are_equal(): + xml1 = """ + + Foo + Bar + 123 + + """ + xml2 = """ + + Bar + Foo + 999 + + """ + e1 = etree.fromstring(xml1) + e2 = etree.fromstring(xml2) + assert xml_elements_equal(e1, e2, ignored_tags=["ignore_me"]) is True + + +def test_xml_elements_are_not_equal(): + xml1 = """ + + Foo + Bar + 123 + + """ + xml2 = """ + + Bar + Foo + 999 + + """ + e1 = etree.fromstring(xml1) + e2 = etree.fromstring(xml2) + assert xml_elements_equal(e1, e2, ignored_tags=["ignore_me"]) is False From b388fb09d24208ac69e38d776e063a50636855f3 Mon Sep 17 00:00:00 2001 From: Thomas Buchberger Date: Sun, 29 Mar 2026 18:08:04 +0200 Subject: [PATCH 3/3] Do not duplicate identical styles --- docxcompose/composer.py | 40 +++++++++++++++++++++++++++++++--------- tests/test_styles.py | 14 ++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docxcompose/composer.py b/docxcompose/composer.py index 63ae23b..72cb754 100644 --- a/docxcompose/composer.py +++ b/docxcompose/composer.py @@ -51,6 +51,7 @@ def __init__(self, doc, preserve_styles=False): self.restart_numbering = True self.preserve_styles = preserve_styles + self._preserved_styles = {} self.reset_reference_mapping() @@ -69,7 +70,7 @@ def append(self, doc, remove_property_fields=True): def insert(self, index, doc, remove_property_fields=True): """Insert the given document at the given index.""" self.reset_reference_mapping() - self._preserved_styles = {} + self._current_preserved_styles = {} # Remove custom property fields but keep the values if remove_property_fields: @@ -313,14 +314,29 @@ def add_styles(self, doc, element): # To preserve styles with the same id from added documents, we # create a copy and append a suffix to the id and name. if self.preserve_styles and our_style_id in our_style_ids: - if our_style_id not in self._preserved_styles: + if our_style_id not in self._current_preserved_styles: style_element = deepcopy(doc.styles.element.get_by_id(style_id)) our_style_element = self.doc.styles.element.get_by_id(our_style_id) - if not xml_elements_equal( - style_element, - our_style_element, - ignored_tags=IGNORED_STYLE_TAGS, - ): + + # Check if we already have an identical style + preserved_style_ids = self._preserved_styles.get( + our_style_id, [our_style_id] + ) + matched_style_id = None + for pstyle_id in preserved_style_ids: + our_style_element = self.doc.styles.element.get_by_id(pstyle_id) + if xml_elements_equal( + style_element, + our_style_element, + ignored_tags=IGNORED_STYLE_TAGS, + ): + matched_style_id = pstyle_id + self._current_preserved_styles[our_style_id] = ( + style_element.styleId + ) + break + # No matching style found, insert style with a new name + if matched_style_id is None: new_id = increment_name(our_style_id) new_name = None if style_element.name is not None: @@ -335,9 +351,15 @@ def add_styles(self, doc, element): self.doc.styles.element.append(style_element) self.add_numberings(doc, style_element) self.add_linked_styles(doc, style_element) - self._preserved_styles[our_style_id] = style_element.styleId + self._current_preserved_styles[our_style_id] = new_id + self._preserved_styles.setdefault( + our_style_id, [our_style_id] + ).append(new_id) + else: + self._current_preserved_styles[our_style_id] = matched_style_id + for el in xpath(element, ".//w:tblStyle|.//w:pStyle|.//w:rStyle"): - el.val = self._preserved_styles[our_style_id] + el.val = self._current_preserved_styles[our_style_id] elif our_style_id not in our_style_ids: style_element = deepcopy(doc.styles.element.get_by_id(style_id)) if style_element is not None: diff --git a/tests/test_styles.py b/tests/test_styles.py index 06e32fd..cc160d7 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -87,6 +87,20 @@ def test_ignore_styles_with_same_id(): assert "MyCustomStyle_1" not in style_ids +def test_preserve_styles_does_not_duplicate_identical_styles(): + composer = Composer( + Document(docx_path("styles_preserve1.docx")), preserve_styles=True + ) + composer.append(Document(docx_path("styles_preserve2.docx"))) + composer.append(Document(docx_path("styles_preserve2.docx"))) + composer.append(Document(docx_path("styles_preserve1.docx"))) + assert [ + s.style_id + for s in composer.doc.styles + if s.style_id.startswith("MyCustomStyle") + ] == ["MyCustomStyle", "MyCustomStyleZchn", "MyCustomStyle_1"] + + @pytest.fixture def merged_styles(): composer = Composer(Document(docx_path("styles_en.docx")))