From 96536b00b311151e651dea55f11f3644e5fe2b17 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:35:58 +0900 Subject: [PATCH] fix(pages): follow empty lines beside a cell object with text on both sides In a table cell, Hancom sets an empty line reaching an object wrapped square with room for text on both sides beside it, two empty pieces at one height, the cell as tall as with room on one side. The estimate now follows such cells instead of leaving them unsupported; text beside such an object is still not followed. Reading a cell's layout caches, a line's second piece at its first's height no longer counts as a line of its own, which made the cell taller by a line for each. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/hwpx/layout/pages.py | 25 ++++++++++++++++-- ...ject_on_both_sides_beside_empty_lines.hwpx | Bin 0 -> 13776 bytes tests/test_layout_page_estimate.py | 5 ++++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/hancom_saved/pages_cell_object_on_both_sides_beside_empty_lines.hwpx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a18b542..233a3d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ### 고침 +- 쪽 수 추정이 표 칸 안에서 양쪽에 글 자리가 있는 어울림 개체 곁에 빈 줄만 오는 칸도 추정한다. 한/글처럼 빈 줄은 개체 곁에 + 한 높이 두 조각으로 둔다(곁에 글이 오면 지금처럼 지원 밖). 한/글이 저장한 줄 캐시가 있는 칸에서 그런 두 조각을 줄 둘로 + 세어 칸을 조각 수만큼 높게 잡던 것도 바로잡았다. - 쪽 수 추정과 FormFit이 줄 바꿈 뒤 빈 run으로 끝나는 문단의 빈 마지막 줄을 한/글처럼 그 run의 크기로 잡는다(글자보다 작아도). 전에는 작은 run은 무시하고 큰 run은 줄 바꿈 앞 줄까지 키웠다. 끝 run이 더 크면 그 문단의 줄 캐시는 쓰지 않는다. - 쪽 수 추정이 첫 줄에 붙은 어울림 개체의 세로 위치를 한/글처럼 위 간격 위 문단 맨 위에서 잰다. 전에는 첫 줄 맨 위에서 diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index adb8ad62..4adaa347 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -1014,6 +1014,7 @@ def wrapped_lines(self, paragraphs: list[Any], width: int, tables: bool = False raise _Unsupported("an object placed top and bottom or square after the text of a paragraph " "with a layout cache in a cell") lines, narrow, own = _clear_of(self, fixed, top, shape.prev, anchor, obj, bands, empty, width) + _beside_one_side(narrow, empty) if narrow and not _cache_lines(paragraph): raise _Unsupported("text laid out otherwise beside a square-wrapped object in a cell") else: @@ -1035,6 +1036,7 @@ def wrapped_lines(self, paragraphs: list[Any], width: int, tables: bool = False narrow = found else: raise _Unsupported("lines beside a square-wrapped object in a cell that do not settle") + _beside_one_side(narrow, empty) if narrow and ("\n" in text or style.head): raise _Unsupported("a paragraph with margins, a label or line breaks beside a square-wrapped " "object in a cell") @@ -1405,6 +1407,12 @@ def _drawn_lines(measure: _Measure, paragraphs: list[Any], width: int | None = N below = top # where the paragraph's next line would stand for index, segment in enumerate(segments): vertpos = int(segment.get("vertpos", 0)) + if index and vertpos == int(segments[index - 1].get("vertpos", 0)) \ + and int(segment.get("horzpos", 0)) > int(segments[index - 1].get("horzpos", 0)): + follows.append(follows[-1]) # a line's second piece beside an object with text on both sides: at + tops.append(tops[-1]) # the first's height + heights.append(int(segment.get("textheight", segment.get("vertsize", 0)))) + continue follows.append(below) table = pushing.get(index) if table is not None and vertpos == _cell_extent(measure, table) + _down(table.find(f"{HP}pos")) \ @@ -2456,10 +2464,23 @@ def _cell_band(measure: _Measure, obj: Any, top: int, width: int) -> tuple[int, left = left if left >= _MIN_SIDE and flow != "RIGHT_ONLY" else 0 right = right if right >= _MIN_SIDE and flow != "LEFT_ONLY" else 0 if left and right: - raise _Unsupported(f"text on both sides of {_local(obj)} wrapped square in a cell") + return up, foot, _BothSides(min(left, right)) return up, foot, (left or right) or None +class _BothSides(int): + """The room beside an object wrapped square in a cell with text on both sides of it (the narrower side's): an + empty line goes beside it, two empty pieces at one height as tall as the line; text there is not followed.""" + + +def _beside_one_side(narrow: dict[int, int], empty: bool) -> None: + """Text on lines *narrow* beside an object wrapped square in a cell with text on both sides of it (unless + the paragraph is *empty*) is not followed.""" + + if not empty and any(isinstance(room, _BothSides) for room in narrow.values()): + raise _Unsupported("text on both sides of an object wrapped square in a cell") + + def _clear_of(measure: _Measure, metrics: list[tuple[int, int]], top: int, prev: int, anchor: int, obj: Any, bands: list[tuple[int, int, int | None]], empty: bool, width: int ) -> tuple[list[tuple[int, int, int]], dict[int, int], tuple[int, int, int | None] | None]: @@ -2488,7 +2509,7 @@ def _clear_of(measure: _Measure, metrics: list[tuple[int, int]], top: int, prev: y, moved = band[1], True beside = [band[2] for band in around if band[2] is not None and y < band[1] and y + height > band[0]] if beside: - narrow[index] = min(beside) + narrow[index] = _BothSides(min(beside)) if any(isinstance(room, _BothSides) for room in beside) else min(beside) placed.append((y, height, advance)) return placed, narrow, own diff --git a/tests/fixtures/hancom_saved/pages_cell_object_on_both_sides_beside_empty_lines.hwpx b/tests/fixtures/hancom_saved/pages_cell_object_on_both_sides_beside_empty_lines.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..e92234242744f5367548a63dece16c88612ecc6b GIT binary patch literal 13776 zcmeHOg^RGqczFPVA|**32_`ICwn3Ul$c1@I*`o7aI8Ckf6@S!Up2(VGl91x3{)1 zHgvYIvt>7TvuE+Nuz&UsMlv?^&?hhezz>HCWpsr&Izfqn?l#sSQRt2GUlGF8oInn0 zN{Fqoor#65nJ~4wnlu|fHKmiYp{=8H-rM&0FCW{R*pY1W@F9H#lgwR{;$-iDV+_?AkJdHaxYB% zLRm%fnToQ6l&Y$-3N@vTp{1RpF!d9t&%*YHkMoDm*wOB%&&b8X+C<64#s~s^1I@Rc z)6ZyVUjCJ>ALW7a{;ht5sbzm=ic;Cs)WR4-2@RRKSpOvX#ly`?$?@~g`-GeFg`5&M zmx9y_3tJOAHz$2g4r+Fhf7kAhk3a=jiG9)zK?4B%ivRjF5_Y!E5L;&_c5{fK3B>V7 zEg$MCN6mLXeqe@olEc({GtoLpo%XbJ(i37uXIxOe9v+GP1Sf?~08QZ36Bkc51|Mle z!pq)2doV{Ve2Noo!rY4^>=IRK|9IrPgbJRbd@ju~c%lq*E&9yV_js^rxrt~fnZoC^ zldGU-v|Ly7l%o={O_FyZi9LrwKFj+yrs8-P3yqfA z*2_z9Cdnltqgm%@ZOupqF(1iFMnY)YR;ksVmAh!`F&!eQ3^m+M+fFp(3YX z$A@swR1CJ2=^Zq921s1?|783;ZQcWNUnQ(;YUTQ-UcIvPiCtV?*Qlf|xqk zCfFkok5o@6q}$hFd1F|F0&V~`(W#7s07E-Jt8$;J`YV?CNE{j(n+8NG(}Oz2|Kqho zxzA0R)P5ajf1y&1T(^NfJ@d%H&FQ{fmhRn6S}!I$ee1*GWL68cERf9c)3i`5U5C|6 zAAZ88^ld2F%4wHa*cSa9>|U&A&zq;boIi~V*-k-rVToxnCSKC_HGUBTz9?dp@ARFa z%Sf6$G9<|8(1)Eyx=8T1a`{@&%VsRD@bNoVBfh*=cN>rO{Z4O{K4Q)Dp=m1(u}wt; zeG#9#iw`8_2Eef~xxuA(7pXR*d)iZbowLUilBIT~m|x07cpBQ$4#IW+Xs4FhdPJQoaBLFtLdIB&Qt_*Y z1%>(cVy!qm=^9*Nd&I;m&Nji`x;)uLDUvox$(ZRxI z2L9O90lTQKLgiblII)c>rb?DB>b0I*TDsGhA^AR-3sprm6R!+w;Az?fhwW%c`eHFE z3vb*+imOG%!G#jjHh1JasxxaC>N6)87a=i|Ji5%0&P}QXF^W4H7M(978EPy<)TW~@y_Ud~exel+yo#%nRUYq>Jqr}NT)`$<|A;{{aCXqGyEAvt1)tDR ztK{!R?plqde|Cb0VFpVPOHXbP)$QFiLA%aHk=Q#p4ES`7#@`k~PVowR%3}HXOFEy< z0@({&lgNa(hvVWUC}LITf{e*}GGT?i*(cs2D&8U#8d$uUGCT}@Aqfhl$r>E0x3wp- zqlWE1C;ExM28dfqr6O0rYJq&Q%V5+bHFni%QDvgp0i zM>1|mc^3q`=W!ji(>8G*uSG2jGC*D~Xi)B4l+a|kda2HUdqlNhOm~!{w3x@-B;g)< z^0wFwZ>m>F(wwfzxV$N^L20R~(YU8vbFWN zVFRLl4(!iK64u1z+}{vN)K3ccVP8G#q@-Rjo?kTfBxAilRks~c*21`47OO65x8xu+ zo`Ac<lHcfnYS1&LP;agD~m-=H4RJaG1Ih2E8wpEy;eo7w9B$FS3~|BW@1Qe zdE>niIkV6Jc>o3~o{HU5surp5&#%Nv)rHKTB)(8oEYLE%n}2r_&V%xpucSPzM2)4~ zN89IHWjr|V^wE>4P|W@k=h6j#+lwuW=1G3<_}odPS<$lmQLC#*7oJ7+dBjJBqm`ac z&8JF-`8C#218l@0XMEW`7Tft#z7xVFbz1%PZz(y^o^PI;uuI`nma zu!hV^Qcl#npa$(D1I>)L^!iH@eqrZ%^tJS=^@fo%^9tUe)vrd3mVVO+Pug?D#QQXk z<7o^E6P-7R^`nUa7XCtJi+X1}Fhc0B&?A=wi(N>{O$kfJR2_KQ!b0A4Q~Do%Fm4bI zJYwAyk0^ncuQf0B%^E1Y-24>T-7Iq@R1|uT5zkvqql~FUQupcJc&u+HcU_S7qgh?L z#Lfa=HsMEGGg-;8 z>7BDj%R*MTfgAH-JGLnZnlqWn2p+OY9!-?tiV7fhd0?2{a-Jy$T9}#e2)iT`13^b* zUzw1<1P57!wP#d1PpQGUjbby{vlXbeR= z@P>|piA-t(4Qls|YFS@Vv6$5^q7j)n5tgTmNwfSUW`TUMcrj!S7Lk~mB?Xt7M2~?3 z$11|ds(EU(gbN@RDGpAIHhe+x8u;<9y73yl(c=}F(3dRBP~xydb0hu3!~=3RU6K?? zH&>d3$+M1_`q1PB(}1U0oxLYOIlftSa3Zr1Hw148WJ;Z>?8=!^-Jso zn>YyX>+Y%T*ZwP24Z(jR-3NLR_A|AU>`e8&7k()L25u4Ge;&$5or;D~AE zq+J58S*>kw=yrxV76>GttKQzBxw5K|k>SbyAfajsEs!MWm^6fPO%uNyiFybY+l*;L z+vjO|K}R4-XUE#f>y?^1zr|0VauYgPGs&N8)RmjjU`8vvH*3kt>va9(Et8DmDB~OR`wPp{HFfwt7-F4nf5z|tMhpL9xu?FK-|r?V zvcO(+P?^^LsciDDEX1H1xBV3 zY^D*KvIS?R3NdI?z#WY24(0@HcDeUioTzQBFfpt!^_-~Tub1Sv*9ajh)4?dyg_yZ< zW-ju$zjHb{pkJAdJR8RGe^-*s5tHmAISDU0$)8L2F*^2bI_|6hWtTS&%hq~!;*JcM zIafoxu!3S*3GUg8;RgoA`|pGM$GIKd!Z-snA+jyOFUt*dvsxEUali-x>{K| zeRVyiOl13;9&<2=5$vB==;-kzrpkx^6=Ayj>%vasTw4C!z`f4m>pf9Xox96VNXdfs zl>rC*>HIKsuO`Bc_H_6e)~11^^=MJbPESk(03<%R6RO?;e&{i zO{Yv*;wdXQkq(OlTncdd*N>Ytv2Y0~%RBH;>2tS3ge4{AuB#+kOvh?nc8F*69oCu% z8n=@~wWAR@cLGLx^!p~#PcvB`!jwJlZg?V6^*{XPWuo(;@%-e+&L#3W_`0xij@@qu0}5H^l32`e>hU$V41=e}?fpz=42mgEcGa1uJX>30saGkeoWv`Ml++LuGF&=OYci*3i^#TP=6yGDj;h7=d!COk+2YaV5sk8-Yk71-9mo1mM3*1fvv zf$@_C=U7yp1t6dbZ`9$SK+KcW12vP}Oyu=Ip42^PNj(Zc(V5Iec(fQ*O-MZgnO-o~gZnEzwsl)S%ZsbNeqbd(tTA`b)&f|*7MlD%cGeOp%z zr_b#|@lN^vY;4?;d8Y|^Y()eq=%76r5!aXeHBTI=Qx1K+&$dIl@R64;O8)h+aZX_* zFyw<X?ohmw|93^lW~o@=^B`GO`R%V@UW1ut-%3+{)|>b>^!5biZcOB1 zVM`)N5$2vJ&X^IC3wJtBQ5Bf zEQG8SGLT3lTah(;oDp>#0+*b}2n2DWQX6OPM`OqNQ#&5+CT5l=qkLCWd4la;_=O^e zxKB#4-!WgWhE;DPF5u9$_k(A4zA)g_V_e&mQ?}%;>kD4ES<*6?La$KAS)CbX7|WZu ziA`(QQLFx*HBD|WoU-JbHoS857T|4OirT=7mHX6d^O;r<%j1x@9Gv*5kLNg}Z*W!o zm`$Xy5-MRyu0YxjB0NVgr4B33Bfo?YWzX z>LOmhkJn%Q+(KmnNU1|}aT(il`AFclX!x*o@l*u3rpHSm@ntKv-mmo87w;^w=44G^ zPQ;-`?+zt1dHN#y@bOT{V%l_b`!@;8rHN84d^K2s(EJ!t4YQh?YOf4;NTTrI*`WIN zJGaF?nj7UMahL#lO2&xT3;%6qR2`5O8Lvk5m`slD*3&V4JtR=ZDd@8tnVlJ^P7~oXI4;?VtfmFVK;oZqvy&Y? z>=l20086DoJC|HS|V)APMQvOEJa`QOFL!+nq;qFiQe%l%#t&T@wK{AQFiux_Dftb zJoeyBDt@El8f1PI{#{9tZ>kKb5g`RxTdX=R6+(0Kr*Cr$V~hn2Il|*1=h02&swt!# zYTxUeXIxZKVcs0q0gYWANkgusJdULHJGB*1aw7%k^@dfZrG}PdXTL8fm~9R4Z6vC0 zt$RQ)*9uarFTd4wql+LLe84|#njx6&2wixp?d!1|O4fdSOagJY+!kp*a=EY0nE=M~ zSo$F4X3^HUu;L5DQ8qPFG!2&WE`D4o0E+SK!gRef7q?JHAEd6KHW9$!9PFTlFNwcx z4^G9W!?=G5CPYIdAu>FlP8)JL)=V(~(vnn4;e6%VJ>%0k)(&DEBtN6sP^vc(cu%!y|H_+iu=)z8E)xpeRszz=W71sk#hrDf| zS+0qBPx>*;D}y4*dj!EghSnG0v1!qri)?VOs1n!tj2(m_F{81^c7ZaD56qr;wUesS z^=Z5Z`{XO#aEYW`B#aLnASwyE5aWSt5}as-@*+G8(8V)|FRPQ4vx- zN-WS#9TRi>VA-pvg09$`K9}P7f%R}KK2gqk+5x$=fQe1xCly)hmY=Op|^%laoDW6(T*KnO5Jtiu^Lb$oAx&3n0`f&XzK;&+OVEvBm>+p*r zG_8@%`*Ay|6nDfv+8b??4;rRkJ|jiSuI_}c^te=Yw4xV7K)uJ)hmLp$8Z-i6-*-Hi zd13o~#XUVeuU>ajB^VBaP}S>CΞPmO_PQjE)?G~#I02%BDsRuP&rH6xkcq7@o>dSLon+qHtp%ff;mxf z24vd99}Smn@7e~LwdjE2o}<>!BsygWKl_{l6bC}o=+6bSQy#k#bJW0alW?3B_@^}d z^ticM0bLZwJ8%4oip03DtiaNWAqw+L5BsU;$&LE*IMMA!iMcN%RCx`y@V_VYE`W7E zz;Hk87|_>O+0ca43x*0M(^(;#;wb2ZYVwwvQI|3#?@}W+@(Mrg{)W@0FfWCsdIn&R z<1sf4GWE6P8Fwi;YAudBVFaao|u9$w}(*lXxMw00Hzf!PDztRgfWb#2(A!5qDj{}X<>1Nv2ko@i^rrDow^T$~R1sxs`jmq# zc#}5rZ+oWbx_S{6`X=rwE4Qi!&PG0lk?lEmX~74*TEw`sETv|o3_ ze(8SftA+wue|53kWh>&H%85qWGm=p@eR`Xdsbvim3@*j-x=Z~QyU*pXpBB&Ya*;ID z1!zNW()Oi62FL}4bq`s4v%%UWR$QUaHE?U|J}bf1^BP#e)wkePUU2AB+O$oTU&>S* za+s!G+~R}bKG+5w%n+s!mlx+Skud~FpN;AJh6Zue? zk3cjU@*U#dBQo>-K0HL5EG-p&9PZ!|ac!NL zwkY46rNkQ>p^azycHdz~G=f>JJ5GX{Z%@TXr!GMW4|V&c4{-ln|6hq8Nism!{Vh=U z!(jBx5#nkAabthx=&A;BcLqAUJFjng)>yK$Cq=@-H_eS?8vy9h58WH}?3TWI4q_m{ z@%Kl&*)t&m{)sz5z-!h6#Up^)lAuNpfD^>o8EPwX`XNrZMD>H=IkBYt=q8qiYG2Fl zoKr$oH75c4)Vkcr^a;@uGuKz9Ciu2Gdc{ur3l=vCx!3t~;-;wUl`v8Vr#RmXRu3;$ z*B+_iZXg(&R-Y)MPq=1+QhTlzZJ0Ru<1t#JWkc1D*G)lMC)8c$%@zAARc?jto2z-2 zt%-OSo2H~s5@F3gLzY7C&tA@&SmzLROAfx~Zp31rCBv;sZk^uRxY>6MO)yE)<}WEG zOa(29jxUKXt5&WFL{1BDnXaOogLWUJ2Soo}U}))}<{cilR=!u*0KgCT!xs0qij=c~ z8nA%&wq|dhDaoLt5Tig}qsz%kssI466951_00|Zvqr{<^4}HKmsmO=}DuzgRp#gX^ zF-0)|pehRW)(`<2Mz)vLbOHb{I(|5q9=l=_005g@&N$1CL#d;`0oIK$`%|zHU$$v3HcN8r#}8Phd;gH&tULpEc`P!{Fxg6 ztPK7vjQ*?(|169DhzWnBq(4I99~t@ohl&94$_KAsG%>A<>$eT!fNHF|9H-p< z!RlfGtYOcR)F~)dQkk;-BSD41t?zNGOTE7pBv02dHdy(qBFWsb+;UO{SBg%c!iPqW)_Q_pO4cedB&_R#fhrw zMieBL#Ym;EM)Kk~toI*_QnJMom7i`!NefN9%@)(lc}(6-SKDz;5 zh0Szh@6^W~47;6zQ#OKPYJ%_#oLFnj9FDSYXY~HO4#2?J&_6ok;kuYS|mYf3?ui&9Dih0Py zG3JnI9Ph{*R-+r@^f2JLdF6Mr52sw@Rn&#rMX98L<(A7LRlYq-W9ihVY>5M?>K?mH zyU13k(^m;Hm#Pe=JC_*IHB2~xxEOOhCgEv(-YD@U)RlANWPj3UMk;ukKR`QU`W%IH zevY}FyB%a@rgsf%exuJ(SA0UeEiYW)$7cOTY<(oc9u-aX75q++j1c$Q>Qr2#Hd9tm z5@=wf{5@-M*=b{axeg6SwBzK38Ecp>lOO=aegQn5INaKXKwn_YXWvZxt)V9=eCs9U z(ke%`;#%)>!mL-ly%6tYa)q(HZ{y@8Q8POn-|Q^OLgjF}5tDi?JfrjDI()R&*!(hE zmJrPLdu}KNG#|b_E^8GoYhAXxC-=SgZ9pi9_5v0h+Ni=s$8Fz|A&0q1 z|2|a%E*Z5EfwezeO!bedc87ku@&AeliO`kf;=vKvuHR2qx^*rxA^^Nh7!7eK5d6zN&?@DiqWV#7B zXs4fty8oloL)Fwi&j&4GIk9{bqOE2lF4Y1{WlKgR!Vzx(R6-xyZ(|0I_U7F>H7cLk z#r1moafeSbxpNwo0J}7XCKXU3cwK3}R&*SoV`K=_{n#t?pRv-$(AL5f;^h28x&I_m9;Jf=4dFd? z?Xew!Wcq<;;3c42BaF(IIYxDEo}s|`COKs~>R51Go`5~1EonVPE{2r=1iNfEEzz^V zM%^=;9U!`=us))lnJ*nL*F3u9-!FooNAOyT9r#*ZR}06Bxw@c^IrVm=m1((Syb|V_ zJPa%W>i>QG1)5RlMG5c^`0@PrVVK{!en0#2gZ3{j0qFBzM^S#1?-%2LKi2V+?`LvJ z(AM>z5%Jd{kKX})-+}cDfDPks0Ds?)^*hAxMx4Jv5dXsx@o$sP?*PABYkmQ|f&%>A zcJqt#2hIOBd;H>bCHY6v|1pC6Liq19@Gk^5w!gKpf6T|fWBfi5{lc&W{;dxGHZA>* x@cWSP3xS&RzvkkErohR&pSP(lFUe*i{Ii`W1F literal 0 HcmV?d00001 diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 8bc0b3db..b3e74ca6 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -115,6 +115,11 @@ # table placed top and bottom 1958 down from it, then three paragraphs: Hancom places the inner table from the # paragraph's top above its spacing, as in the body, the cell's next paragraph below it from there "pages_cell_table_down_from_spacing": 4, + # Four times on a page of its own: a table whose cell's empty first paragraph holds a 15000 x 8000 rectangle + # wrapped square 12000 from its left, text on both sides of it, 0 or 2000 down, overlapping the text or not, + # then six empty paragraphs and two of text: the empty lines reaching it go beside it (two empty pieces at one + # height), the table as tall as with room on one side only + "pages_cell_object_on_both_sides_beside_empty_lines": 5, "pages_square_band_table_as_character_alone": 1, # 47058 wide: beside it all the same, reaching past the room "pages_square_band_narrow_table_as_character_alone": 1, # 5000 wide: beside it, and the lines after # ... or text whose lines in the room beside the picture are as wide as it, and an object set as a