Skip to content
Closed
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added pdf/issue7687/titled-host-2020.pdf
Binary file not shown.
Binary file not shown.
63 changes: 63 additions & 0 deletions samples/issue7687/gen_titled_host_after_band.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""빈 host 의 양수 오프셋 자리차지 표 띠 뒤에 오는 **글 있는 host** 의 자리차지 표 위치를 보는
HWPX 생성기. 모든 문단에 linesegarray 가 없다(한/글이 직접 조판). 쪽마다 사례 하나.

python3 gen_titled_host_after_band.py samples/issue1549_multipositive_float_tables.hwpx titled-host.hwpx

빈 host(T1 오프셋 0 / T2 오프셋 15000HU) 뒤 빈 문단이 T2 띠에 걸려 띠 아래로 가고, 그 다음
글 있는 host(T3 오프셋 0 / T4 오프셋 9000HU)가 온다. T4 의 위끝은 host 문단 원점 + 오프셋 +
바깥 위 여백이다. 그 원점은 앞 띠의 끝에서 정해지므로, 띠 끝을 잘못 세면 T3 는 그대로인데
T4·제목·뒤 글만 어긋난다(한글문서파일형식 20쪽 pi=133, #7687).
"""
import re
import sys
import zipfile

from gen_empty_host import empty, host, nid, para, table

T3 = (0, 0, 6000, "T3")
T4 = (9000, 20000, 6000, "T4")


def titled_host(text, tables=(T3, T4)):
ctrls = "".join(table(*t) for t in tables)
return (f'<hp:p id="{nid()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'<hp:run charPrIDRef="0">{ctrls}<hp:t>{text}</hp:t></hp:run></hp:p>')


CASES = [
("K1 빈 host·빈 문단 둘 뒤 제목 host",
lambda: [host(None), empty(), empty(), titled_host("K1 제목")] + [para(f"K1 글 {i}") for i in range(1, 4)]),
("K2 빈 host·빈 문단 넷 뒤 제목 host",
lambda: [host(None)] + [empty() for _ in range(4)] + [titled_host("K2 제목")]
+ [para(f"K2 글 {i}") for i in range(1, 4)]),
("K3 빈 host 바로 뒤 제목 host",
lambda: [host(None), titled_host("K3 제목")] + [para(f"K3 글 {i}") for i in range(1, 4)]),
("K4 대조군 — 앞 빈 host 없음",
lambda: [para("K4 앞 글"), titled_host("K4 제목")] + [para(f"K4 글 {i}") for i in range(1, 4)]),
]


def main():
template, out = sys.argv[1], sys.argv[2]
zin = zipfile.ZipFile(template)
sec = zin.read("Contents/section0.xml").decode("utf-8")
sec_head = sec[:sec.index("<hp:p ")]
sec_run = re.search(r'<hp:p [^>]*>(<hp:run charPrIDRef="0"><hp:secPr.*?</hp:run>)', sec, re.S).group(1)
body = [f'<hp:p id="{nid()}" paraPrIDRef="0" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">'
f'{sec_run}<hp:run charPrIDRef="0"><hp:t>#7687 띠 뒤 제목 host 실험</hp:t></hp:run></hp:p>']
for i, (name, build) in enumerate(CASES):
body.append(para(f"사례 {name}", page_break=i > 0))
body.extend(build())
new_sec = sec_head + "".join(body) + "</hs:sec>"
with zipfile.ZipFile(out, "w") as zout:
for item in zin.infolist():
if item.filename.startswith("Preview/"):
continue
data = new_sec.encode("utf-8") if item.filename == "Contents/section0.xml" else zin.read(item.filename)
ct = zipfile.ZIP_STORED if item.filename == "mimetype" else zipfile.ZIP_DEFLATED
zout.writestr(item, data, compress_type=ct)


if __name__ == "__main__":
main()
Binary file added samples/issue7687/titled-host.hwpx
Binary file not shown.
Binary file not shown.
35 changes: 28 additions & 7 deletions src/renderer/typeset/table/block/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1627,6 +1627,11 @@ impl TypesetEngine {
table,
ft.effective_height,
);
// [#7687] 빈 공동 앵커 host 의 자리차지 표도 글이 있는 host 와 같은 visible float
// 배치를 탄다. 그 배치는 표 원점에 바깥 위 여백을, 흐름 끝·배제 띠 끝에 바깥 아래
// 여백을 스스로 더하므로 여기서는 표 상자 높이만 넘긴다.
let coanchored_visible_float = is_para_topbottom_float(&table.common)
&& crate::renderer::float_placement::is_textless_coanchored_float_host(para);
let whole_placement_height = if let Some(placement) = closed_source_frame_placement
.or(captioned_column_placement)
.or(consumed_whole_anchor)
Expand All @@ -1637,7 +1642,9 @@ impl TypesetEngine {
} else if let Some(advance) = single_row_object_height_advance {
advance
} else if is_para_topbottom_float(&table.common)
&& (para_has_non_whitespace_text(para) || hwpx_noninline_tac_measured_fit)
&& (para_has_non_whitespace_text(para)
|| coanchored_visible_float
|| hwpx_noninline_tac_measured_fit)
{
ft.effective_height
} else {
Expand Down Expand Up @@ -1783,12 +1790,21 @@ impl TypesetEngine {
});
let unconstrained_host_placement = para_has_non_whitespace_text(para)
.then(|| {
let text_origin = source_text_origin.unwrap_or(placement_para_start_height
let para_text_origin = placement_para_start_height
+ if placement_para_start_height > 0.0 {
fmt.spacing_before
} else {
0.0
});
};
let text_origin = source_text_origin.unwrap_or(para_text_origin);
// [#8156] 문단 기준 세로 오프셋은 문단 원점에서 잰다. 같은 문단의 앞
// 자리차지 표가 흐름을 소비하면 저장 첫 줄은 그 띠 아래로 밀려 저장된다.
// 글줄은 그 저장 원점을 따르지만, 표 앵커는 앞 표와 같은 문단 원점을 쓴다.
let anchor_text_origin = if has_preceding_coanchored_float {
para_text_origin
} else {
text_origin
};
if let Some(lines) = &fmt.computed_host_lines {
crate::renderer::float_placement::ParagraphFloatPlacement::from_computed_host(
para,
Expand All @@ -1815,7 +1831,7 @@ impl TypesetEngine {
para,
table,
ctrl_idx,
text_origin,
anchor_text_origin,
whole_placement_height,
self.dpi,
)
Expand All @@ -1824,9 +1840,13 @@ impl TypesetEngine {
para,
table,
ctrl_idx,
source_text_origin.map_or(placement_para_start_height, |origin| {
origin - if placement_para_start_height > 0.0 { fmt.spacing_before } else { 0.0 }
}),
if has_preceding_coanchored_float {
placement_para_start_height
} else {
source_text_origin.map_or(placement_para_start_height, |origin| {
origin - if placement_para_start_height > 0.0 { fmt.spacing_before } else { 0.0 }
})
},
whole_placement_height,
self.dpi,
)
Expand Down Expand Up @@ -2207,6 +2227,7 @@ impl TypesetEngine {
// 사라지지 않는다. fit에서는 면제하되, 별도 프레임이 소유하지 않는
// 순차 배치에서는 마지막 조각과 같이 후속 흐름에 한 번 전달한다.
let unreserved_flow_tail = if resolved_host_placement.is_none()
&& !coanchored_visible_float
&& saved_table_source_frame.is_none()
&& single_row_object_height_advance.is_none()
&& matches!(
Expand Down
59 changes: 59 additions & 0 deletions tests/cases/issue_7687_empty_host_multi_float_gap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,62 @@ fn synthetic_empty_host_cases_follow_hancom_gap_rule() {
t2_bottom
);
}

/// 합성 K1~K4 (`samples/issue7687/gen_titled_host_after_band.py`, 모든 문단에 저장 줄 없음).
/// 빈 host(T1 오프셋 0 / T2 오프셋 15000HU) 의 띠 뒤에 글 있는 host(T3 오프셋 0 / T4 오프셋
/// 9000HU)가 온다. 표마다 바깥 여백 283HU(3.77px). 한/글 2020 독립 Print
/// `pdf/issue7687/titled-host-2020.pdf` 와 대조한다.
///
/// 빈 host 표의 배제 띠 끝을 바깥 여백만큼 더 세면 뒤 글 host 의 원점이 내려가, 흐름이 놓는
/// T3 는 그대로인데 그 원점에서 재는 T4 와 T4 아래 글만 어긋난다(한글문서파일형식 20쪽 pi=133).
#[test]
fn titled_host_after_empty_host_band_keeps_its_float_offsets() {
const OUTER: f64 = 283.0 * 96.0 / 7200.0;
const T4_OFFSET: f64 = 9000.0 * 96.0 / 7200.0;
let doc = load("samples/issue7687/titled-host.hwpx");
assert_eq!(doc.page_count(), 4, "정본 PDF 와 같은 4쪽");
let near = |what: &str, actual: f64, expected: f64| {
assert!(
(actual - expected).abs() <= 1.0,
"{what}: 실제 {actual:.1}, 기대 {expected:.1} (±1px)"
);
};

// K1·K3: T4 의 자연 위치가 T2 띠 안이라 띠 끝 아래에 쌓인다
// (T4 윗변 = T2 아랫변 + T2 아래 여백 + T4 위 여백). 뒤 글줄은 T4 띠 끝에 선다.
for (page, empty_host, titled, next_text) in [(0u32, 2u64, 5u64, 5u64), (2, 20, 21, 21)] {
let t = tree(&doc, page);
let (_, t2_bottom) = table(&t, empty_host, 1);
let (t3_top, _) = table(&t, titled, 0);
let (t4_top, t4_bottom) = table(&t, titled, 1);
assert!(t3_top < t4_top, "K{} T3 는 T4 보다 위", page + 1);
near(
&format!("K{} T4 윗변", page + 1),
t4_top,
t2_bottom + OUTER + OUTER,
);
near(
&format!("K{} T4 아래 첫 글줄", page + 1),
line(&t, next_text).0,
t4_bottom + OUTER,
);
}

// K2·K4: T4 는 자기 host 원점에서 오프셋만큼 아래다. T3 와 T4 는 같은 host·같은 위 여백이라
// 두 윗변의 차이가 곧 오프셋 차이다. T4 아래 첫 글줄은 T4 띠 끝에 선다.
for (page, titled, after_t4) in [(1u32, 15u64, 17u64), (3, 27, 29)] {
let t = tree(&doc, page);
let (t3_top, _) = table(&t, titled, 0);
let (t4_top, t4_bottom) = table(&t, titled, 1);
near(
&format!("K{} T4 윗변 − T3 윗변", page + 1),
t4_top - t3_top,
T4_OFFSET,
);
near(
&format!("K{} T4 아래 첫 글줄", page + 1),
line(&t, after_t4).0,
t4_bottom + OUTER,
);
}
}
144 changes: 144 additions & 0 deletions tests/cases/issue_8156_saved_host_offset_float_anchor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#![cfg(not(target_arch = "wasm32"))]

//! [#8156] 저장 줄이 있는 글 host 의 양수 오프셋 자리차지 표는 문단 원점에서 잰다.
//!
//! 입력은 #7687 합성 K1~K4(`samples/issue7687/gen_titled_host_after_band.py` 의 HWPX)를
//! 한/글 2020 으로 HWP 저장한 것이다(저장 줄 있음). 글 host 마다 T3(오프셋 0)와
//! T4(오프셋 9000HU)가 있고 표마다 바깥 여백 283HU 이다. 한/글은 T3 가 흐름을 소비하므로
//! host 첫 줄을 T3 띠 아래에 저장한다. 같은 HWP 의 한/글 2020 독립 Print
//! `pdf/issue8156/titled-host-hancom2020-saved-2020.pdf` 와 대조한다.
//!
//! T4 의 세로 오프셋을 그 밀린 저장 줄에서 재면 T4 가 T3 띠만큼(87.5px) 내려가
//! 뒤 본문과 겹친다.

use rhwp::wasm_api::HwpDocument;
use serde_json::Value;

const SAMPLE: &str = "samples/issue8156/titled-host-hancom2020-saved.hwp";
const OUTER: f64 = 283.0 * 96.0 / 7200.0;
const T4_OFFSET: f64 = 9000.0 * 96.0 / 7200.0;

fn load(rel: &str) -> HwpDocument {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
HwpDocument::from_bytes(&bytes).expect("표본 파싱")
}

fn tree(doc: &HwpDocument, page: u32) -> Value {
serde_json::from_str(&doc.get_page_render_tree(page).unwrap()).unwrap()
}

/// 본문 직속 Table 노드 (pi, ci, 윗변, 아랫변). 표 안은 내려가지 않는다.
fn tables(tree: &Value) -> Vec<(u64, u64, f64, f64)> {
fn walk(node: &Value, out: &mut Vec<(u64, u64, f64, f64)>) {
if node["type"].as_str() == Some("Table") {
let y = node["bbox"]["y"].as_f64().unwrap();
let h = node["bbox"]["h"].as_f64().unwrap();
out.push((
node["pi"].as_u64().unwrap(),
node["ci"].as_u64().unwrap(),
y,
y + h,
));
return;
}
for child in node["children"].as_array().into_iter().flatten() {
walk(child, out);
}
}
let mut out = Vec::new();
walk(tree, &mut out);
out
}

fn table(tree: &Value, pi: u64, ci: u64) -> (f64, f64) {
tables(tree)
.into_iter()
.find(|t| t.0 == pi && t.1 == ci)
.map(|t| (t.2, t.3))
.unwrap_or_else(|| panic!("표 pi={pi} ci={ci}: {:?}", tables(tree)))
}

/// 본문 직속 글줄 (pi, 윗변, 아랫변). 표 안은 내려가지 않는다.
fn lines(tree: &Value) -> Vec<(u64, f64, f64)> {
fn walk(node: &Value, out: &mut Vec<(u64, f64, f64)>) {
let kind = node["type"].as_str().unwrap_or_default();
if kind == "Table" {
return;
}
if kind == "TextLine" {
let y = node["bbox"]["y"].as_f64().unwrap();
let h = node["bbox"]["h"].as_f64().unwrap();
out.push((node["pi"].as_u64().unwrap(), y, y + h));
}
for child in node["children"].as_array().into_iter().flatten() {
walk(child, out);
}
}
let mut out = Vec::new();
walk(tree, &mut out);
out
}

fn line(tree: &Value, pi: u64) -> (f64, f64) {
lines(tree)
.into_iter()
.find(|l| l.0 == pi)
.map(|l| (l.1, l.2))
.unwrap_or_else(|| panic!("글줄 pi={pi}: {:?}", lines(tree)))
}

fn near(what: &str, actual: f64, expected: f64) {
assert!(
(actual - expected).abs() <= 1.0,
"{what}: 실제 {actual:.1}, 기대 {expected:.1} (±1px)"
);
}

#[test]
fn saved_titled_host_measures_offset_float_from_paragraph_origin() {
let doc = load(SAMPLE);
assert_eq!(doc.page_count(), 4, "정본 PDF 와 같은 4쪽");

// K2·K4: T3 와 T4 는 같은 문단 원점·같은 위 여백이라 두 윗변의 차이가 곧 오프셋이다.
// host 첫 글줄은 저장 줄대로 T3 띠 끝에, 뒤 글줄은 T4 띠 끝에 선다.
for (page, titled, after_t4) in [(1u32, 15u64, 16u64), (3, 27, 28)] {
let t = tree(&doc, page);
let (t3_top, t3_bottom) = table(&t, titled, 0);
let (t4_top, t4_bottom) = table(&t, titled, 1);
let k = page + 1;
near(
&format!("K{k} T4 윗변 − T3 윗변"),
t4_top - t3_top,
T4_OFFSET,
);
let (host_top, host_bottom) = line(&t, titled);
near(&format!("K{k} host 첫 글줄"), host_top, t3_bottom + OUTER);
assert!(
host_bottom <= t4_top,
"K{k} host 글줄 {host_top:.1}..{host_bottom:.1} 은 T4 윗변 {t4_top:.1} 위"
);
near(
&format!("K{k} T4 아래 첫 글줄"),
line(&t, after_t4).0,
t4_bottom + OUTER,
);
}

// K1·K3: T4 의 자연 위치가 앞 빈 host 의 T2 띠 안이라 띠 끝 아래에 쌓인다
// (T4 윗변 = T2 아랫변 + T2 아래 여백 + T4 위 여백). host 글줄은 T4 띠 끝에 선다.
for (page, empty_host, titled) in [(0u32, 2u64, 5u64), (2, 20, 21)] {
let t = tree(&doc, page);
let (_, t2_bottom) = table(&t, empty_host, 1);
let (t3_top, _) = table(&t, titled, 0);
let (t4_top, t4_bottom) = table(&t, titled, 1);
let k = page + 1;
assert!(t3_top < t4_top, "K{k} T3 는 T4 보다 위");
near(&format!("K{k} T4 윗변"), t4_top, t2_bottom + OUTER + OUTER);
near(
&format!("K{k} T4 아래 host 글줄"),
line(&t, titled).0,
t4_bottom + OUTER,
);
}
}
Loading