Can we get dialogue wrap working
This commit is contained in:
parent
9db8089076
commit
3fcde613a8
5 changed files with 846 additions and 133 deletions
|
|
@ -2812,10 +2812,16 @@ class WolfWorkflowTab(QWidget):
|
||||||
work = self._wolf_json_work_dir()
|
work = self._wolf_json_work_dir()
|
||||||
return [work] if work is not None else []
|
return [work] if work is not None else []
|
||||||
|
|
||||||
def _wrap_profile_width(self, sheet_name: str) -> int:
|
def _wrap_profile_width(self, sheet_name: str, *, kind: str | None = None) -> int:
|
||||||
work = self._wolf_json_work_dir()
|
work = self._wolf_json_work_dir()
|
||||||
if work is not None:
|
if work is not None:
|
||||||
profile = wolf_ws.load_wrap_profile(work)
|
profile = wolf_ws.load_wrap_profile(work)
|
||||||
|
if kind in ("map", "common"):
|
||||||
|
geom = wolf_ws.get_format_geometry(
|
||||||
|
profile, self._current_wrap_speaker_src()
|
||||||
|
)
|
||||||
|
if geom.get("width"):
|
||||||
|
return int(geom["width"])
|
||||||
return wolf_ws.get_sheet_width(
|
return wolf_ws.get_sheet_width(
|
||||||
profile,
|
profile,
|
||||||
sheet_name,
|
sheet_name,
|
||||||
|
|
@ -2826,6 +2832,47 @@ class WolfWorkflowTab(QWidget):
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 36
|
return 36
|
||||||
|
|
||||||
|
def _current_wrap_speaker_src(self) -> str:
|
||||||
|
kw = self._current_wrap_speaker_kw()
|
||||||
|
return str(kw.get("speaker_src") or "")
|
||||||
|
|
||||||
|
def _load_dialogue_geometry_into_spins(self):
|
||||||
|
"""Load wrap settings for this line's ``speaker_src`` format bucket."""
|
||||||
|
work = self._wolf_json_work_dir()
|
||||||
|
width = None
|
||||||
|
max_lines = None
|
||||||
|
font = None
|
||||||
|
speaker_src = self._current_wrap_speaker_src()
|
||||||
|
if work is not None:
|
||||||
|
geom = wolf_ws.get_format_geometry(
|
||||||
|
wolf_ws.load_wrap_profile(work), speaker_src
|
||||||
|
)
|
||||||
|
width = geom.get("width")
|
||||||
|
max_lines = geom.get("max_lines")
|
||||||
|
font = geom.get("font")
|
||||||
|
if width is None:
|
||||||
|
try:
|
||||||
|
width = int(self._setting("wrap_fix_width", 36) or 36)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
width = 36
|
||||||
|
if max_lines is None:
|
||||||
|
max_lines = 0
|
||||||
|
self._wrap_width_spin.blockSignals(True)
|
||||||
|
self._wrap_width_spin.setValue(int(width))
|
||||||
|
self._wrap_width_spin.blockSignals(False)
|
||||||
|
if hasattr(self, "_wrap_manual_width_spin"):
|
||||||
|
self._wrap_manual_width_spin.blockSignals(True)
|
||||||
|
self._wrap_manual_width_spin.setValue(int(width))
|
||||||
|
self._wrap_manual_width_spin.blockSignals(False)
|
||||||
|
if hasattr(self, "_wrap_max_lines_spin"):
|
||||||
|
self._wrap_max_lines_spin.blockSignals(True)
|
||||||
|
self._wrap_max_lines_spin.setValue(int(max_lines))
|
||||||
|
self._wrap_max_lines_spin.blockSignals(False)
|
||||||
|
if font is not None and hasattr(self, "_wrap_font_spin"):
|
||||||
|
self._wrap_font_spin.blockSignals(True)
|
||||||
|
self._wrap_font_spin.setValue(int(font))
|
||||||
|
self._wrap_font_spin.blockSignals(False)
|
||||||
|
|
||||||
def _remember_sheet_width(
|
def _remember_sheet_width(
|
||||||
self,
|
self,
|
||||||
sheet_name: str,
|
sheet_name: str,
|
||||||
|
|
@ -2833,16 +2880,30 @@ class WolfWorkflowTab(QWidget):
|
||||||
json_file: str,
|
json_file: str,
|
||||||
*,
|
*,
|
||||||
max_lines: int | None = None,
|
max_lines: int | None = None,
|
||||||
|
font: int | None = None,
|
||||||
|
kind: str | None = None,
|
||||||
|
speaker_src: str | None = None,
|
||||||
):
|
):
|
||||||
work = self._wolf_json_work_dir()
|
work = self._wolf_json_work_dir()
|
||||||
if work is not None:
|
if work is None:
|
||||||
wolf_ws.set_sheet_width(
|
return
|
||||||
|
if kind in ("map", "common"):
|
||||||
|
src = speaker_src if speaker_src is not None else self._current_wrap_speaker_src()
|
||||||
|
wolf_ws.set_format_geometry(
|
||||||
work,
|
work,
|
||||||
sheet_name,
|
src,
|
||||||
width,
|
width=width,
|
||||||
json_file=json_file,
|
|
||||||
max_lines=max_lines,
|
max_lines=max_lines,
|
||||||
|
font=font,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
wolf_ws.set_sheet_width(
|
||||||
|
work,
|
||||||
|
sheet_name,
|
||||||
|
width,
|
||||||
|
json_file=json_file,
|
||||||
|
max_lines=max_lines,
|
||||||
|
)
|
||||||
|
|
||||||
def _wrap_mode(self) -> str:
|
def _wrap_mode(self) -> str:
|
||||||
combo = getattr(self, "_wrap_mode_combo", None)
|
combo = getattr(self, "_wrap_mode_combo", None)
|
||||||
|
|
@ -3050,11 +3111,32 @@ class WolfWorkflowTab(QWidget):
|
||||||
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
||||||
f"row {hit.row} · {hit.field_name}{overflow_note}"
|
f"row {hit.row} · {hit.field_name}{overflow_note}"
|
||||||
)
|
)
|
||||||
|
if hit.kind in ("map", "common"):
|
||||||
|
fmt = wolf_ws.wrap_format_label(self._current_wrap_speaker_src())
|
||||||
|
return (
|
||||||
|
f"{kind_lbl}: {scope.label} · {hit.json_file} · "
|
||||||
|
f"{hit.field_name}{overflow_note} · "
|
||||||
|
f"shared wrap: {fmt}"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
||||||
f"{hit.field_name}{overflow_note}"
|
f"{hit.field_name}{overflow_note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _current_wrap_speaker_kw(self) -> dict[str, str]:
|
||||||
|
"""speaker_src / speaker for nameplate-aware wrap preview."""
|
||||||
|
line = None
|
||||||
|
if self._current_wrap_doc is not None and self._current_wrap_hit_id is not None:
|
||||||
|
line = wolf_ws.locate_line(
|
||||||
|
self._current_wrap_doc, self._current_wrap_hit_id
|
||||||
|
)
|
||||||
|
if isinstance(line, dict):
|
||||||
|
return {
|
||||||
|
"speaker_src": str(line.get("speaker_src") or ""),
|
||||||
|
"speaker": str(line.get("speaker") or ""),
|
||||||
|
}
|
||||||
|
return {"speaker_src": "", "speaker": ""}
|
||||||
|
|
||||||
def _update_wrap_preview(self):
|
def _update_wrap_preview(self):
|
||||||
if not hasattr(self, "_wrap_preview"):
|
if not hasattr(self, "_wrap_preview"):
|
||||||
return
|
return
|
||||||
|
|
@ -3071,11 +3153,21 @@ class WolfWorkflowTab(QWidget):
|
||||||
text = self._current_wrap_hit.text
|
text = self._current_wrap_hit.text
|
||||||
width = self._active_wrap_width()
|
width = self._active_wrap_width()
|
||||||
body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None
|
body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None
|
||||||
|
plate_kw = self._current_wrap_speaker_kw()
|
||||||
# Manual: preview scaled fonts. Relayout + max lines: preview fit-to-box.
|
# Manual: preview scaled fonts. Relayout + max lines: preview fit-to-box.
|
||||||
preview_src = text
|
preview_src = text
|
||||||
preview_width = width
|
preview_width = width
|
||||||
if body_font is not None and preview_src.strip():
|
if body_font is not None and preview_src.strip():
|
||||||
preview_src, _ = wolf_codes.scale_font_sizes(preview_src, body_font)
|
prefix, nameplate, body = wolf_ws.split_nameplate_body(
|
||||||
|
preview_src, **plate_kw
|
||||||
|
)
|
||||||
|
work = body if nameplate else preview_src
|
||||||
|
work, _ = wolf_codes.scale_font_sizes(work, body_font)
|
||||||
|
preview_src = (
|
||||||
|
wolf_ws.join_nameplate_body(prefix, nameplate, work)
|
||||||
|
if nameplate
|
||||||
|
else work
|
||||||
|
)
|
||||||
elif self._wrap_mode() == "relayout" and width > 0:
|
elif self._wrap_mode() == "relayout" and width > 0:
|
||||||
max_lines = self._wrap_max_lines_value()
|
max_lines = self._wrap_max_lines_value()
|
||||||
if max_lines > 0 and preview_src.strip():
|
if max_lines > 0 and preview_src.strip():
|
||||||
|
|
@ -3090,23 +3182,34 @@ class WolfWorkflowTab(QWidget):
|
||||||
if isinstance(source, str) and wolf_codes.detect_font_sizes(source):
|
if isinstance(source, str) and wolf_codes.detect_font_sizes(source):
|
||||||
box_font = wolf_codes.infer_base_font_size(source)
|
box_font = wolf_codes.infer_base_font_size(source)
|
||||||
preview_src, _ = wolf_ws.fit_text_to_box(
|
preview_src, _ = wolf_ws.fit_text_to_box(
|
||||||
preview_src, width, max_lines=max_lines, box_font=box_font
|
preview_src,
|
||||||
|
width,
|
||||||
|
max_lines=max_lines,
|
||||||
|
box_font=box_font,
|
||||||
|
**plate_kw,
|
||||||
)
|
)
|
||||||
# Soft breaks are already final; don't reflow at the narrow cell width.
|
# Soft breaks are already final; don't reflow at the narrow cell width.
|
||||||
|
_, nameplate, body = wolf_ws.split_nameplate_body(
|
||||||
|
preview_src, **plate_kw
|
||||||
|
)
|
||||||
|
measure = body if nameplate else preview_src
|
||||||
preview_width = max(
|
preview_width = max(
|
||||||
width,
|
width,
|
||||||
dazedwrap.max_line_visible_length(preview_src),
|
dazedwrap.max_line_visible_length(measure),
|
||||||
)
|
)
|
||||||
summary = wolf_ws.wrap_preview_summary(preview_src, preview_width)
|
summary = wolf_ws.wrap_preview_summary(
|
||||||
|
preview_src, preview_width, **plate_kw
|
||||||
|
)
|
||||||
preview_html = wolf_ws.format_wrap_preview_html(
|
preview_html = wolf_ws.format_wrap_preview_html(
|
||||||
preview_src if self._wrap_mode() == "relayout" else text,
|
preview_src if self._wrap_mode() == "relayout" else text,
|
||||||
preview_width,
|
preview_width,
|
||||||
body_font=body_font,
|
body_font=body_font,
|
||||||
|
**plate_kw,
|
||||||
)
|
)
|
||||||
info = wolf_ws.wrap_preview_info(preview_src, preview_width)
|
info = wolf_ws.wrap_preview_info(preview_src, preview_width, **plate_kw)
|
||||||
if hasattr(self, "_wrap_preview_label"):
|
if hasattr(self, "_wrap_preview_label"):
|
||||||
if summary:
|
if summary:
|
||||||
soft = wolf_ws.count_soft_lines(preview_src)
|
soft = wolf_ws.count_body_soft_lines(preview_src, **plate_kw)
|
||||||
max_lines = self._wrap_max_lines_value()
|
max_lines = self._wrap_max_lines_value()
|
||||||
if (
|
if (
|
||||||
self._wrap_mode() == "relayout"
|
self._wrap_mode() == "relayout"
|
||||||
|
|
@ -3137,7 +3240,10 @@ class WolfWorkflowTab(QWidget):
|
||||||
over_lines = False
|
over_lines = False
|
||||||
if self._wrap_mode() == "relayout":
|
if self._wrap_mode() == "relayout":
|
||||||
max_lines = self._wrap_max_lines_value()
|
max_lines = self._wrap_max_lines_value()
|
||||||
over_lines = max_lines > 0 and wolf_ws.count_soft_lines(preview_src) > max_lines
|
over_lines = (
|
||||||
|
max_lines > 0
|
||||||
|
and wolf_ws.count_body_soft_lines(preview_src, **plate_kw) > max_lines
|
||||||
|
)
|
||||||
border = "#ce9178" if info.get("needs_wrap") or over_lines else "#007acc"
|
border = "#ce9178" if info.get("needs_wrap") or over_lines else "#007acc"
|
||||||
self._wrap_preview.setStyleSheet(
|
self._wrap_preview.setStyleSheet(
|
||||||
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
|
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
|
||||||
|
|
@ -3199,8 +3305,10 @@ class WolfWorkflowTab(QWidget):
|
||||||
self._current_wrap_doc = doc
|
self._current_wrap_doc = doc
|
||||||
if hit.kind == "names":
|
if hit.kind == "names":
|
||||||
self._load_names_geometry_into_spins(hit)
|
self._load_names_geometry_into_spins(hit)
|
||||||
|
elif hit.kind in ("map", "common"):
|
||||||
|
self._load_dialogue_geometry_into_spins()
|
||||||
else:
|
else:
|
||||||
w = self._wrap_profile_width(hit.sheet_name)
|
w = self._wrap_profile_width(hit.sheet_name, kind=hit.kind)
|
||||||
self._wrap_width_spin.blockSignals(True)
|
self._wrap_width_spin.blockSignals(True)
|
||||||
self._wrap_width_spin.setValue(w)
|
self._wrap_width_spin.setValue(w)
|
||||||
self._wrap_width_spin.blockSignals(False)
|
self._wrap_width_spin.blockSignals(False)
|
||||||
|
|
@ -3271,6 +3379,8 @@ class WolfWorkflowTab(QWidget):
|
||||||
self._current_wrap_hit.sheet_name,
|
self._current_wrap_hit.sheet_name,
|
||||||
width,
|
width,
|
||||||
self._current_wrap_hit.json_file,
|
self._current_wrap_hit.json_file,
|
||||||
|
font=font,
|
||||||
|
kind=self._current_wrap_hit.kind,
|
||||||
)
|
)
|
||||||
path, doc, line = wolf_ws.load_hit_from_id(
|
path, doc, line = wolf_ws.load_hit_from_id(
|
||||||
self._translated_and_files_dirs()[0],
|
self._translated_and_files_dirs()[0],
|
||||||
|
|
@ -3327,6 +3437,7 @@ class WolfWorkflowTab(QWidget):
|
||||||
width,
|
width,
|
||||||
self._current_wrap_hit.json_file,
|
self._current_wrap_hit.json_file,
|
||||||
max_lines=max_lines or None,
|
max_lines=max_lines or None,
|
||||||
|
kind=self._current_wrap_hit.kind,
|
||||||
)
|
)
|
||||||
path, doc, line = wolf_ws.load_hit_from_id(
|
path, doc, line = wolf_ws.load_hit_from_id(
|
||||||
self._translated_and_files_dirs()[0],
|
self._translated_and_files_dirs()[0],
|
||||||
|
|
@ -3364,14 +3475,18 @@ class WolfWorkflowTab(QWidget):
|
||||||
if self._wrap_mode() == "manual":
|
if self._wrap_mode() == "manual":
|
||||||
width = self._active_wrap_width()
|
width = self._active_wrap_width()
|
||||||
font = self._wrap_font_value()
|
font = self._wrap_font_value()
|
||||||
|
tdir = self._translated_and_files_dirs()[0]
|
||||||
count = wolf_ws.wrap_overflow_manual_in_scope(
|
count = wolf_ws.wrap_overflow_manual_in_scope(
|
||||||
self._current_wrap_path,
|
self._current_wrap_path,
|
||||||
self._current_wrap_doc,
|
self._current_wrap_doc,
|
||||||
hit,
|
hit,
|
||||||
width,
|
width,
|
||||||
font=font,
|
font=font,
|
||||||
|
translated_dir=tdir if hit.kind in ("map", "common") else None,
|
||||||
|
)
|
||||||
|
self._remember_sheet_width(
|
||||||
|
hit.sheet_name, width, hit.json_file, font=font, kind=hit.kind
|
||||||
)
|
)
|
||||||
self._remember_sheet_width(hit.sheet_name, width, hit.json_file)
|
|
||||||
if hit.kind == "names":
|
if hit.kind == "names":
|
||||||
roles_path = wolf_names.roles_json_path_for_names(self._current_wrap_path)
|
roles_path = wolf_names.roles_json_path_for_names(self._current_wrap_path)
|
||||||
wolf_names.upsert_name_wrap_role(
|
wolf_names.upsert_name_wrap_role(
|
||||||
|
|
@ -3417,15 +3532,21 @@ class WolfWorkflowTab(QWidget):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
max_lines = self._wrap_max_lines_value()
|
max_lines = self._wrap_max_lines_value()
|
||||||
|
tdir = self._translated_and_files_dirs()[0]
|
||||||
count = wolf_ws.wrap_overflow_in_scope(
|
count = wolf_ws.wrap_overflow_in_scope(
|
||||||
self._current_wrap_path,
|
self._current_wrap_path,
|
||||||
self._current_wrap_doc,
|
self._current_wrap_doc,
|
||||||
hit,
|
hit,
|
||||||
width,
|
width,
|
||||||
max_lines=max_lines or None,
|
max_lines=max_lines or None,
|
||||||
|
translated_dir=tdir if hit.kind in ("map", "common") else None,
|
||||||
)
|
)
|
||||||
self._remember_sheet_width(
|
self._remember_sheet_width(
|
||||||
hit.sheet_name, width, hit.json_file, max_lines=max_lines or None
|
hit.sheet_name,
|
||||||
|
width,
|
||||||
|
hit.json_file,
|
||||||
|
max_lines=max_lines or None,
|
||||||
|
kind=hit.kind,
|
||||||
)
|
)
|
||||||
scope = wolf_ws.scope_label(hit, self._current_wrap_doc)
|
scope = wolf_ws.scope_label(hit, self._current_wrap_doc)
|
||||||
if max_lines > 0:
|
if max_lines > 0:
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,25 @@ class WolfCodesRepairTests(unittest.TestCase):
|
||||||
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
|
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
|
||||||
self.assertEqual(fixed, text)
|
self.assertEqual(fixed, text)
|
||||||
|
|
||||||
|
def test_rebuild_keeps_nameplate_body_font(self):
|
||||||
|
"""Spoken ``Name\\n\\f[N]body`` must not become ``\\f[N]Name\\n`` on inject repair."""
|
||||||
|
source = (
|
||||||
|
"市民\n俺達の税金で好き放題しやがって……、\n"
|
||||||
|
"王子が代行として実権を握ってからは\n"
|
||||||
|
"毎晩毎晩パーティー三昧だって話じゃねえか……。"
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"Citizen\n\\f[21]Spending our tax money however he\n"
|
||||||
|
"pleases...... ever since the Prince\n"
|
||||||
|
"took over as regent, it's been\n"
|
||||||
|
"nothing but parties every single\n"
|
||||||
|
"night, I tell you......"
|
||||||
|
)
|
||||||
|
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
|
||||||
|
self.assertEqual(fixed, text)
|
||||||
|
self.assertIn("Spending our tax money", fixed)
|
||||||
|
self.assertFalse(fixed.rstrip().endswith("Citizen"))
|
||||||
|
|
||||||
def test_document_has_font_size_drift_for_db(self):
|
def test_document_has_font_size_drift_for_db(self):
|
||||||
doc = {
|
doc = {
|
||||||
"kind": "db",
|
"kind": "db",
|
||||||
|
|
|
||||||
|
|
@ -243,58 +243,87 @@ class TestManualFontAndWrap(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class TestEventScopeWrap(unittest.TestCase):
|
class TestEventScopeWrap(unittest.TestCase):
|
||||||
def test_wrap_all_in_map_scene_only(self):
|
def test_wrap_all_in_spoken_format_across_scenes(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "Map001.mps.json"
|
root = Path(tmp)
|
||||||
long_a = (
|
long_a = (
|
||||||
|
"Celria\n"
|
||||||
"This is a very long line of dialogue that "
|
"This is a very long line of dialogue that "
|
||||||
"should wrap at thirty four visible chars."
|
"should wrap at thirty four visible chars."
|
||||||
)
|
)
|
||||||
long_b = (
|
long_b = (
|
||||||
|
"Citizen\n"
|
||||||
"Another very long line of dialogue that also "
|
"Another very long line of dialogue that also "
|
||||||
"should wrap at thirty four visible chars."
|
"should wrap at thirty four visible chars."
|
||||||
)
|
)
|
||||||
|
ui_line = (
|
||||||
|
"\\f[24]Rosa profile card that is also long enough "
|
||||||
|
"to wrap at thirty four visible chars easily."
|
||||||
|
)
|
||||||
|
path = root / "CommonEvent.dat.json"
|
||||||
doc = {
|
doc = {
|
||||||
"kind": "map",
|
"kind": "common",
|
||||||
"file": "Map001.mps",
|
"file": "CommonEvent.dat",
|
||||||
"scenes": [
|
"scenes": [
|
||||||
{
|
{
|
||||||
"event": 1,
|
"event": 1,
|
||||||
"name": "Intro",
|
"name": "Intro",
|
||||||
"lines": [
|
"lines": [
|
||||||
{"text": long_a},
|
{
|
||||||
{"text": "Short line."},
|
"speaker": "セルリア",
|
||||||
|
"speaker_src": "literal_line1_lowconf",
|
||||||
|
"text": long_a,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"event": 2,
|
"event": 2,
|
||||||
"name": "Other",
|
"name": "Town",
|
||||||
"lines": [{"text": long_b}],
|
"lines": [
|
||||||
|
{
|
||||||
|
"speaker": "市民",
|
||||||
|
"speaker_src": "literal_line1_lowconf",
|
||||||
|
"text": long_b,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": "UI",
|
||||||
|
"speaker_src": "ui",
|
||||||
|
"text": ui_line,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||||
hit = ws.WrapHit(
|
hit = ws.WrapHit(
|
||||||
json_file="Map001.mps.json",
|
json_file="CommonEvent.dat.json",
|
||||||
kind="map",
|
kind="common",
|
||||||
sheet_name="Map001.mps",
|
sheet_name="CommonEvent",
|
||||||
row=1,
|
row=1,
|
||||||
field_name="UI",
|
field_name="セルリア",
|
||||||
text="very long line",
|
text="very long line",
|
||||||
max_line_len=80,
|
max_line_len=80,
|
||||||
scene_index=0,
|
scene_index=0,
|
||||||
line_index=0,
|
line_index=0,
|
||||||
map_file="Map001.mps",
|
|
||||||
)
|
)
|
||||||
stats = ws.scope_stats(doc, hit, 34)
|
stats = ws.scope_stats(doc, hit, 34)
|
||||||
self.assertEqual(stats.total, 2)
|
self.assertEqual(stats.total, 2)
|
||||||
self.assertEqual(stats.overflow, 1)
|
self.assertEqual(stats.overflow, 2)
|
||||||
self.assertIn("event 1", stats.label)
|
self.assertIn("spoken", stats.label)
|
||||||
self.assertIn("Intro", stats.label)
|
changed = ws.wrap_overflow_in_scope(
|
||||||
self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1)
|
path, doc, hit, 34, translated_dir=root
|
||||||
# Scene 2 must stay untouched.
|
)
|
||||||
self.assertEqual(doc["scenes"][1]["lines"][0]["text"], long_b)
|
self.assertEqual(changed, 2)
|
||||||
self.assertNotEqual(doc["scenes"][0]["lines"][0]["text"], long_a)
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
a = saved["scenes"][0]["lines"][0]["text"]
|
||||||
|
b = saved["scenes"][1]["lines"][0]["text"]
|
||||||
|
ui = saved["scenes"][1]["lines"][1]["text"]
|
||||||
|
self.assertTrue(a.startswith("Celria\n"))
|
||||||
|
self.assertTrue(b.startswith("Citizen\n"))
|
||||||
|
self.assertNotEqual(a, long_a)
|
||||||
|
self.assertNotEqual(b, long_b)
|
||||||
|
# UI format must stay untouched.
|
||||||
|
self.assertEqual(ui, ui_line)
|
||||||
|
|
||||||
|
|
||||||
class TestFitTextToBox(unittest.TestCase):
|
class TestFitTextToBox(unittest.TestCase):
|
||||||
|
|
@ -389,5 +418,95 @@ class TestFitTextToBox(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNameplateWrap(unittest.TestCase):
|
||||||
|
TEXT = (
|
||||||
|
"Celria\n"
|
||||||
|
"The number of cases involving 'illegal Magic Drugs' and 'monster attacks' "
|
||||||
|
"seems strangely high\n"
|
||||||
|
"for a town of this size."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preserves_speaker_newline(self):
|
||||||
|
wrapped = ws.wrap_preserving_nameplate(
|
||||||
|
self.TEXT,
|
||||||
|
50,
|
||||||
|
speaker_src="literal_line1_lowconf",
|
||||||
|
speaker="セルリア",
|
||||||
|
)
|
||||||
|
self.assertTrue(wrapped.startswith("Celria\n"))
|
||||||
|
self.assertNotIn("Celria The", wrapped)
|
||||||
|
|
||||||
|
def test_never_wipes_body(self):
|
||||||
|
"""Regression: group wrap must not leave ``\\f[N]Name\\n`` with empty body."""
|
||||||
|
text = (
|
||||||
|
"Citizen\n"
|
||||||
|
"Spending our tax money however he pleases...... ever since\n"
|
||||||
|
"the Prince took over as regent, it's been nothing but\n"
|
||||||
|
"parties every single night, I tell you......"
|
||||||
|
)
|
||||||
|
kw = dict(speaker_src="literal_line1_lowconf", speaker="市民")
|
||||||
|
for width, max_lines, font in (
|
||||||
|
(35, 2, None),
|
||||||
|
(40, 1, None),
|
||||||
|
(21, 2, 21),
|
||||||
|
(50, 0, 18),
|
||||||
|
):
|
||||||
|
if font is not None:
|
||||||
|
out, _ = ws.apply_manual_font_and_wrap(
|
||||||
|
text, width, font=font, **kw
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out, _ = ws.fit_text_to_box(
|
||||||
|
text, width, max_lines=max_lines or None, **kw
|
||||||
|
)
|
||||||
|
self.assertIn("\n", out, msg=repr(out))
|
||||||
|
body = out.split("\n", 1)[1]
|
||||||
|
self.assertTrue(body.strip(), msg=f"wiped at w={width}: {out!r}")
|
||||||
|
self.assertIn("Spending", body)
|
||||||
|
self.assertIn("parties", body)
|
||||||
|
|
||||||
|
def test_fit_max_lines_counts_body_only(self):
|
||||||
|
fitted, changed = ws.fit_text_to_box(
|
||||||
|
self.TEXT,
|
||||||
|
40,
|
||||||
|
max_lines=2,
|
||||||
|
speaker_src="literal_line1_lowconf",
|
||||||
|
speaker="セルリア",
|
||||||
|
)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.assertTrue(fitted.startswith("Celria\n"))
|
||||||
|
body_lines = ws.count_body_soft_lines(
|
||||||
|
fitted,
|
||||||
|
speaker_src="literal_line1_lowconf",
|
||||||
|
speaker="セルリア",
|
||||||
|
)
|
||||||
|
self.assertLessEqual(body_lines, 2)
|
||||||
|
|
||||||
|
def test_dialogue_geometry_roundtrip(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
work = Path(tmp)
|
||||||
|
ws.set_format_geometry(
|
||||||
|
work, "literal_line1_lowconf", width=48, max_lines=3, font=16
|
||||||
|
)
|
||||||
|
ws.set_format_geometry(work, "ui", width=28, max_lines=8, font=24)
|
||||||
|
spoken = ws.get_format_geometry(
|
||||||
|
ws.load_wrap_profile(work), "literal_line1"
|
||||||
|
)
|
||||||
|
ui = ws.get_format_geometry(ws.load_wrap_profile(work), "ui")
|
||||||
|
self.assertEqual(spoken["width"], 48)
|
||||||
|
self.assertEqual(spoken["max_lines"], 3)
|
||||||
|
self.assertEqual(ui["width"], 28)
|
||||||
|
self.assertEqual(ui["font"], 24)
|
||||||
|
# Spoken aliases share one bucket.
|
||||||
|
self.assertEqual(
|
||||||
|
ws.wrap_format_key("literal_line1"),
|
||||||
|
ws.wrap_format_key("literal_line1_lowconf"),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
ws.wrap_format_key("literal_line1"),
|
||||||
|
ws.wrap_format_key("ui"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,10 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
||||||
``\\f[N]`` sizes are taken from *text* when present (manual wrap / names-wrap
|
``\\f[N]`` sizes are taken from *text* when present (manual wrap / names-wrap
|
||||||
shrink). Other control codes (``\\c``, ``\\^``, …) always come from *source*.
|
shrink). Other control codes (``\\c``, ``\\^``, …) always come from *source*.
|
||||||
A leading body ``\\f[N]`` that *source* lacks is kept from *text*.
|
A leading body ``\\f[N]`` that *source* lacks is kept from *text*.
|
||||||
|
|
||||||
|
When *source* has no inline codes at all (typical spoken ``Name\\nbody``),
|
||||||
|
*text* is returned unchanged so a body ``\\f[N]`` after the nameplate cannot
|
||||||
|
be mis-read as a code slot and wipe the dialogue.
|
||||||
"""
|
"""
|
||||||
if not isinstance(source, str) or not isinstance(text, str):
|
if not isinstance(source, str) or not isinstance(text, str):
|
||||||
return text
|
return text
|
||||||
|
|
@ -272,6 +276,13 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
||||||
if not src_parts:
|
if not src_parts:
|
||||||
return out_prefix + txt_rest
|
return out_prefix + txt_rest
|
||||||
|
|
||||||
|
# Source has no inline codes to restore. Keep the translation intact -
|
||||||
|
# including Manual-wrap ``Name\\n\\f[N]body`` (body font after the nameplate).
|
||||||
|
# Slot-rebuilding would treat the mid-line ``\\f`` as a break, keep only
|
||||||
|
# ``Name\\n``, prepend ``\\f[N]``, and drop the body (``\\f[N]Name\\n``).
|
||||||
|
if not any(kind == "code" for kind, _ in src_parts):
|
||||||
|
return out_prefix + txt_rest
|
||||||
|
|
||||||
# Rebuild using source code skeleton + translated literal slots.
|
# Rebuild using source code skeleton + translated literal slots.
|
||||||
# Font sizes prefer the translation (intentional shrink / body \\f).
|
# Font sizes prefer the translation (intentional shrink / body \\f).
|
||||||
rebuilt: list[str] = []
|
rebuilt: list[str] = []
|
||||||
|
|
@ -297,8 +308,14 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
||||||
rebuilt.append(val)
|
rebuilt.append(val)
|
||||||
else:
|
else:
|
||||||
if lit_i < len(txt_lit):
|
if lit_i < len(txt_lit):
|
||||||
rebuilt.append(txt_lit[lit_i])
|
# Last source literal: keep every remaining translated literal so
|
||||||
lit_i += 1
|
# extra mid-line ``\\f[N]`` in *text* cannot truncate the body.
|
||||||
|
if lit_i == len(src_lit) - 1 and len(txt_lit) > lit_i + 1:
|
||||||
|
rebuilt.append("".join(txt_lit[lit_i:]))
|
||||||
|
lit_i = len(txt_lit)
|
||||||
|
else:
|
||||||
|
rebuilt.append(txt_lit[lit_i])
|
||||||
|
lit_i += 1
|
||||||
else:
|
else:
|
||||||
rebuilt.append(val)
|
rebuilt.append(val)
|
||||||
return out_prefix + "".join(rebuilt)
|
return out_prefix + "".join(rebuilt)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
"""Search translated WolfDawn JSON for text to fix wrapping.
|
"""Search translated WolfDawn JSON for text to fix wrapping.
|
||||||
|
|
||||||
Paste in-game text, open the matching line, then wrap that line or every
|
Paste in-game text, open the matching line, then wrap that line or every
|
||||||
overflowing line in the same group (database sheet, names category, map /
|
overflowing line in the same group:
|
||||||
CommonEvent file, or Game.dat).
|
|
||||||
|
* database sheet / names category / Game.dat file
|
||||||
|
* map / CommonEvent: group = ``speaker_src`` format (spoken / ui / …) across
|
||||||
|
all map + CommonEvent JSON; shared width/font remembered per format
|
||||||
|
|
||||||
|
Dialogue lines with ``speaker_src`` ``literal_line1`` / ``literal_line1_lowconf``
|
||||||
|
keep the nameplate on its own first line (``Celria\\n…``); only the body is
|
||||||
|
reflowed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -18,6 +25,8 @@ from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text
|
||||||
|
|
||||||
WRAP_PROFILE_NAME = "wrap_profile.json"
|
WRAP_PROFILE_NAME = "wrap_profile.json"
|
||||||
DEFAULT_WRAP_WIDTH = 36
|
DEFAULT_WRAP_WIDTH = 36
|
||||||
|
# Legacy single-bucket key (migrated to spoken format on read).
|
||||||
|
DIALOGUE_PROFILE_KEY = "__dialogue__"
|
||||||
|
|
||||||
_APOSTROPHE_NORMALIZE = str.maketrans(
|
_APOSTROPHE_NORMALIZE = str.maketrans(
|
||||||
{
|
{
|
||||||
|
|
@ -29,6 +38,29 @@ _APOSTROPHE_NORMALIZE = str.maketrans(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_format_key(speaker_src: str | None) -> str:
|
||||||
|
"""Profile key for shared wrap geometry by WolfDawn ``speaker_src``.
|
||||||
|
|
||||||
|
Same detection class ≈ same on-screen box across maps/CommonEvent.
|
||||||
|
``literal_line1`` and ``literal_line1_lowconf`` share one spoken-dialogue
|
||||||
|
bucket; other tags (``ui``, ``narration``, ``choice``, …) each get their own.
|
||||||
|
"""
|
||||||
|
src = str(speaker_src or "").strip().lower() or "default"
|
||||||
|
if src in ("literal_line1", "literal_line1_lowconf"):
|
||||||
|
return "__format:spoken__"
|
||||||
|
safe = re.sub(r"[^a-z0-9_]+", "_", src).strip("_") or "default"
|
||||||
|
return f"__format:{safe}__"
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_format_label(speaker_src: str | None) -> str:
|
||||||
|
"""Short UI label for the format bucket of *speaker_src*."""
|
||||||
|
key = wrap_format_key(speaker_src)
|
||||||
|
if key == "__format:spoken__":
|
||||||
|
return "spoken dialogue (Name\\nbody)"
|
||||||
|
tag = key.removeprefix("__format:").removesuffix("__")
|
||||||
|
return f"format:{tag}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WrapHit:
|
class WrapHit:
|
||||||
"""One searchable line in translated JSON."""
|
"""One searchable line in translated JSON."""
|
||||||
|
|
@ -174,6 +206,191 @@ def get_sheet_max_lines(
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_format_geometry(
|
||||||
|
profile: dict[str, Any],
|
||||||
|
speaker_src: str | None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Return shared wrap settings for this ``speaker_src`` format bucket."""
|
||||||
|
sheets = profile.get("sheets") or {}
|
||||||
|
key = wrap_format_key(speaker_src)
|
||||||
|
entry = sheets.get(key)
|
||||||
|
# Migrate legacy single dialogue bucket → spoken format only.
|
||||||
|
if not isinstance(entry, dict) and key == "__format:spoken__":
|
||||||
|
entry = sheets.get(DIALOGUE_PROFILE_KEY)
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return out
|
||||||
|
for field in ("width", "max_lines", "font"):
|
||||||
|
if entry.get(field) is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[field] = int(entry[field])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_format_geometry(
|
||||||
|
work_dir: str | Path,
|
||||||
|
speaker_src: str | None,
|
||||||
|
*,
|
||||||
|
width: int | None = None,
|
||||||
|
max_lines: int | None = None,
|
||||||
|
font: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist wrap settings for one ``speaker_src`` format (map + CommonEvent)."""
|
||||||
|
profile = load_wrap_profile(work_dir)
|
||||||
|
sheets = profile.setdefault("sheets", {})
|
||||||
|
key = wrap_format_key(speaker_src)
|
||||||
|
entry = sheets.get(key)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
entry = {"json_file": key, "speaker_src": str(speaker_src or "")}
|
||||||
|
sheets[key] = entry
|
||||||
|
if width is not None and int(width) > 0:
|
||||||
|
entry["width"] = int(width)
|
||||||
|
if max_lines is not None:
|
||||||
|
if int(max_lines) > 0:
|
||||||
|
entry["max_lines"] = int(max_lines)
|
||||||
|
else:
|
||||||
|
entry.pop("max_lines", None)
|
||||||
|
if font is not None:
|
||||||
|
if int(font) > 0:
|
||||||
|
entry["font"] = int(font)
|
||||||
|
else:
|
||||||
|
entry.pop("font", None)
|
||||||
|
entry["speaker_src"] = str(speaker_src or "")
|
||||||
|
save_wrap_profile(work_dir, profile)
|
||||||
|
|
||||||
|
|
||||||
|
# Back-compat aliases (spoken / legacy __dialogue__).
|
||||||
|
def get_dialogue_geometry(profile: dict[str, Any]) -> dict[str, int]:
|
||||||
|
return get_format_geometry(profile, "literal_line1")
|
||||||
|
|
||||||
|
|
||||||
|
def set_dialogue_geometry(
|
||||||
|
work_dir: str | Path,
|
||||||
|
*,
|
||||||
|
width: int | None = None,
|
||||||
|
max_lines: int | None = None,
|
||||||
|
font: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
set_format_geometry(
|
||||||
|
work_dir,
|
||||||
|
"literal_line1",
|
||||||
|
width=width,
|
||||||
|
max_lines=max_lines,
|
||||||
|
font=font,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def split_nameplate_body(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
"""Split ``Name\\nbody`` dialogue into ``(prefix, nameplate, body)``.
|
||||||
|
|
||||||
|
Uses WolfDawn ``speaker_src`` (``literal_line1`` / ``literal_line1_lowconf``)
|
||||||
|
when enabled. Falls back to matching the ``speaker`` field to line 1.
|
||||||
|
When there is no nameplate, returns ``("", "", text)``.
|
||||||
|
"""
|
||||||
|
from util import speakers as wolf_speakers
|
||||||
|
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
return "", "", text if isinstance(text, str) else ""
|
||||||
|
|
||||||
|
# Normalize newlines so ``\\r\\n`` nameplates still split cleanly.
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
src = str(speaker_src or "")
|
||||||
|
if wolf_speakers.is_firstline_enabled(src):
|
||||||
|
parts = wolf_speakers.split_source(text, src)
|
||||||
|
if parts is not None:
|
||||||
|
prefix, line1, body = parts
|
||||||
|
return prefix, line1, body
|
||||||
|
|
||||||
|
name = str(speaker or "").strip()
|
||||||
|
if name and "\n" in text:
|
||||||
|
prefix, rest = wolf_speakers.split_window_prefix(text)
|
||||||
|
line1, body = rest.split("\n", 1)
|
||||||
|
# Match plain name or ``\\f[N]Name`` nameplate to the speaker field.
|
||||||
|
plate_core = re.sub(r"^(\\f\[\d+\])+", "", line1).strip()
|
||||||
|
if plate_core == name or line1.strip() == name:
|
||||||
|
return prefix, line1, body
|
||||||
|
return "", "", text
|
||||||
|
|
||||||
|
|
||||||
|
def join_nameplate_body(prefix: str, nameplate: str, body: str) -> str:
|
||||||
|
"""Reassemble a nameplate dialogue line."""
|
||||||
|
if nameplate:
|
||||||
|
return f"{prefix}{nameplate}\n{body}"
|
||||||
|
return f"{prefix}{body}"
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_empty_body_result(original: str, new_text: str) -> str:
|
||||||
|
"""Never allow wrap/font passes to drop a previously non-empty body.
|
||||||
|
|
||||||
|
A past group-wrap bug left thousands of spoken lines as ``\\f[N]Name\\n`` with
|
||||||
|
an empty body. Refuse that class of result and keep *original*.
|
||||||
|
"""
|
||||||
|
if not isinstance(original, str) or not isinstance(new_text, str):
|
||||||
|
return original if isinstance(original, str) else new_text
|
||||||
|
if "\n" not in original:
|
||||||
|
return new_text
|
||||||
|
old_body = original.split("\n", 1)[1]
|
||||||
|
if not old_body.strip():
|
||||||
|
return new_text
|
||||||
|
if "\n" not in new_text:
|
||||||
|
return original
|
||||||
|
new_body = new_text.split("\n", 1)[1]
|
||||||
|
if not new_body.strip():
|
||||||
|
return original
|
||||||
|
return new_text
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_preserving_nameplate(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Word-wrap *text*, keeping a leading speaker nameplate on its own line."""
|
||||||
|
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||||
|
return text if isinstance(text, str) else ""
|
||||||
|
prefix, nameplate, body = split_nameplate_body(
|
||||||
|
text, speaker_src=speaker_src, speaker=speaker
|
||||||
|
)
|
||||||
|
if not nameplate:
|
||||||
|
return wrap_line_text(text, width)
|
||||||
|
if not body.strip():
|
||||||
|
return text
|
||||||
|
wrapped_body = wrap_line_text(body, width)
|
||||||
|
if not wrapped_body.strip() and body.strip():
|
||||||
|
return text
|
||||||
|
return _reject_empty_body_result(
|
||||||
|
text, join_nameplate_body(prefix, nameplate, wrapped_body)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def line_needs_wrap_preserving_nameplate(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Overflow check that ignores the nameplate line (body only when present)."""
|
||||||
|
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||||
|
return False
|
||||||
|
prefix, nameplate, body = split_nameplate_body(
|
||||||
|
text, speaker_src=speaker_src, speaker=speaker
|
||||||
|
)
|
||||||
|
check = body if nameplate else text
|
||||||
|
return line_needs_wrap(check, width)
|
||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict[str, Any] | None:
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
|
@ -346,7 +563,13 @@ def search_translated_text(
|
||||||
return hits
|
return hits
|
||||||
|
|
||||||
|
|
||||||
def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
def wrap_preview_info(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Return wrapped text and metrics for the Step 7 live preview."""
|
"""Return wrapped text and metrics for the Step 7 live preview."""
|
||||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -357,9 +580,21 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
||||||
"output_line_count": 0,
|
"output_line_count": 0,
|
||||||
"line_stats": [],
|
"line_stats": [],
|
||||||
}
|
}
|
||||||
wrapped = wrap_line_text(text, width)
|
wrapped = wrap_preserving_nameplate(
|
||||||
norm_in = text.replace("\r\n", "\n").replace("\r", "\n")
|
text, width, speaker_src=speaker_src, speaker=speaker
|
||||||
norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n")
|
)
|
||||||
|
_, nameplate, body = split_nameplate_body(
|
||||||
|
text, speaker_src=speaker_src, speaker=speaker
|
||||||
|
)
|
||||||
|
# Metrics / overflow are about the dialogue body (nameplate is outside the box).
|
||||||
|
measure_in = body if nameplate else text
|
||||||
|
measure_out = (
|
||||||
|
split_nameplate_body(wrapped, speaker_src=speaker_src, speaker=speaker)[2]
|
||||||
|
if nameplate
|
||||||
|
else wrapped
|
||||||
|
)
|
||||||
|
norm_in = measure_in.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
norm_out = measure_out.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
in_lines = norm_in.split("\n") if norm_in else [""]
|
in_lines = norm_in.split("\n") if norm_in else [""]
|
||||||
out_lines = norm_out.split("\n") if norm_out else [""]
|
out_lines = norm_out.split("\n") if norm_out else [""]
|
||||||
line_stats: list[dict[str, Any]] = []
|
line_stats: list[dict[str, Any]] = []
|
||||||
|
|
@ -382,20 +617,33 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
||||||
"input_line_count": len(in_lines),
|
"input_line_count": len(in_lines),
|
||||||
"output_line_count": len(out_lines),
|
"output_line_count": len(out_lines),
|
||||||
"line_stats": line_stats,
|
"line_stats": line_stats,
|
||||||
|
"nameplate": nameplate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def wrap_preview_summary(text: str, width: int) -> str:
|
def wrap_preview_summary(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> str:
|
||||||
"""One-line status for the preview header."""
|
"""One-line status for the preview header."""
|
||||||
info = wrap_preview_info(text, width)
|
info = wrap_preview_info(
|
||||||
|
text, width, speaker_src=speaker_src, speaker=speaker
|
||||||
|
)
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
return ""
|
return ""
|
||||||
longest = int(info["longest"])
|
longest = int(info["longest"])
|
||||||
|
plate = f" (nameplate kept)" if info.get("nameplate") else ""
|
||||||
if not info["needs_wrap"]:
|
if not info["needs_wrap"]:
|
||||||
return f"Fits at width {width} (longest line {longest} visible chars)."
|
return (
|
||||||
|
f"Fits at width {width} (longest body line {longest} visible chars)"
|
||||||
|
f"{plate}."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"Will wrap to {info['output_line_count']} line(s) at width {width} "
|
f"Will wrap body to {info['output_line_count']} line(s) at width {width} "
|
||||||
f"(longest input line {longest} visible chars)."
|
f"(longest body line {longest} visible chars){plate}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -499,41 +747,52 @@ def format_wrap_preview_html(
|
||||||
width: int,
|
width: int,
|
||||||
*,
|
*,
|
||||||
body_font: int | None = None,
|
body_font: int | None = None,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling.
|
"""Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling.
|
||||||
|
|
||||||
When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a
|
When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a
|
||||||
leading body size) before wrapping/rendering - same as Manual wrap would do.
|
leading body size) before wrapping/rendering - same as Manual wrap would do.
|
||||||
|
Nameplate lines stay on their own first row in the preview.
|
||||||
"""
|
"""
|
||||||
from util.wolfdawn import codes as wolf_codes
|
from util.wolfdawn import codes as wolf_codes
|
||||||
|
|
||||||
preview_text = text if isinstance(text, str) else ""
|
preview_text = text if isinstance(text, str) else ""
|
||||||
if body_font is not None and int(body_font) > 0 and preview_text.strip():
|
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||||
preview_text, _ = wolf_codes.scale_font_sizes(preview_text, int(body_font))
|
prefix, nameplate, body = split_nameplate_body(preview_text, **plate_kw)
|
||||||
|
work = body if nameplate else preview_text
|
||||||
|
if body_font is not None and int(body_font) > 0 and work.strip():
|
||||||
|
work, _ = wolf_codes.scale_font_sizes(work, int(body_font))
|
||||||
|
preview_text = (
|
||||||
|
join_nameplate_body(prefix, nameplate, work) if nameplate else work
|
||||||
|
)
|
||||||
|
|
||||||
info = wrap_preview_info(preview_text, width)
|
info = wrap_preview_info(preview_text, width, **plate_kw)
|
||||||
wrapped = info.get("wrapped") or ""
|
wrapped = info.get("wrapped") or ""
|
||||||
if not wrapped:
|
if not wrapped:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
body = (
|
default_px = (
|
||||||
int(body_font)
|
int(body_font)
|
||||||
if body_font is not None and int(body_font) > 0
|
if body_font is not None and int(body_font) > 0
|
||||||
else wolf_codes.infer_base_font_size(preview_text, default=18)
|
else wolf_codes.infer_base_font_size(work if nameplate else preview_text, default=18)
|
||||||
)
|
)
|
||||||
lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
rows: list[str] = []
|
rows: list[str] = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
|
is_nameplate = bool(nameplate) and i == 0 and line == nameplate
|
||||||
vis = dazedwrap.max_line_visible_length(line)
|
vis = dazedwrap.max_line_visible_length(line)
|
||||||
overflow = vis > width > 0
|
# Nameplate sits outside the message box - never flag as overflow.
|
||||||
gutter_color = "#ce9178" if overflow else "#858585"
|
overflow = (not is_nameplate) and vis > width > 0
|
||||||
marker = "⚠" if overflow else " "
|
gutter_color = "#ce9178" if overflow else ("#c8b89a" if is_nameplate else "#858585")
|
||||||
|
marker = "⚠" if overflow else ("◆" if is_nameplate else " ")
|
||||||
gutter = (
|
gutter = (
|
||||||
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
|
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
|
||||||
f"{marker} {i + 1:2d} ({vis:2d}) </span>"
|
f"{marker} {i + 1:2d} ({vis:2d}) </span>"
|
||||||
)
|
)
|
||||||
body_html = render_wolf_text_html(
|
body_html = render_wolf_text_html(
|
||||||
line, default_color="#d4d4d4", default_font_px=body
|
line, default_color="#d4d4d4", default_font_px=default_px
|
||||||
)
|
)
|
||||||
rows.append(
|
rows.append(
|
||||||
f'<div style="white-space:pre-wrap;line-height:1.35;margin:0 0 2px 0">'
|
f'<div style="white-space:pre-wrap;line-height:1.35;margin:0 0 2px 0">'
|
||||||
|
|
@ -626,7 +885,12 @@ def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool:
|
||||||
text = line.get("text")
|
text = line.get("text")
|
||||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||||
return False
|
return False
|
||||||
new_text = wrap_line_text(text, width)
|
new_text = wrap_preserving_nameplate(
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
speaker_src=str(line.get("speaker_src") or ""),
|
||||||
|
speaker=str(line.get("speaker") or ""),
|
||||||
|
)
|
||||||
if new_text == text:
|
if new_text == text:
|
||||||
return False
|
return False
|
||||||
line["text"] = new_text
|
line["text"] = new_text
|
||||||
|
|
@ -641,6 +905,19 @@ def count_soft_lines(text: str) -> int:
|
||||||
return max(1, len(norm.split("\n")))
|
return max(1, len(norm.split("\n")))
|
||||||
|
|
||||||
|
|
||||||
|
def count_body_soft_lines(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> int:
|
||||||
|
"""Soft line count for the message body (excludes nameplate when present)."""
|
||||||
|
_, nameplate, body = split_nameplate_body(
|
||||||
|
text, speaker_src=speaker_src, speaker=speaker
|
||||||
|
)
|
||||||
|
return count_soft_lines(body if nameplate else text)
|
||||||
|
|
||||||
|
|
||||||
def fit_text_to_box(
|
def fit_text_to_box(
|
||||||
text: str,
|
text: str,
|
||||||
width: int,
|
width: int,
|
||||||
|
|
@ -648,6 +925,8 @@ def fit_text_to_box(
|
||||||
max_lines: int | None = None,
|
max_lines: int | None = None,
|
||||||
min_font: int = 8,
|
min_font: int = 8,
|
||||||
box_font: int | None = None,
|
box_font: int | None = None,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
"""Reflow *text* to *width*, then shrink ``\\f[N]`` until it fits *max_lines*.
|
"""Reflow *text* to *width*, then shrink ``\\f[N]`` until it fits *max_lines*.
|
||||||
|
|
||||||
|
|
@ -659,18 +938,49 @@ def fit_text_to_box(
|
||||||
``round(width * box_font / current_font)`` (same idea as ``wolf relayout`` /
|
``round(width * box_font / current_font)`` (same idea as ``wolf relayout`` /
|
||||||
``desc-relayout``). *box_font* defaults to the text's current body size
|
``desc-relayout``). *box_font* defaults to the text's current body size
|
||||||
(or 18 when there is no ``\\f`` yet).
|
(or 18 when there is no ``\\f`` yet).
|
||||||
|
|
||||||
|
Nameplate lines (``Celria\\n…``) are kept intact; only the body is reflowed
|
||||||
|
and counted toward *max_lines*.
|
||||||
"""
|
"""
|
||||||
|
return _fit_text_to_box_impl(
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
max_lines=max_lines,
|
||||||
|
min_font=min_font,
|
||||||
|
box_font=box_font,
|
||||||
|
speaker_src=speaker_src,
|
||||||
|
speaker=speaker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_text_to_box_impl(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
max_lines: int | None = None,
|
||||||
|
min_font: int = 8,
|
||||||
|
box_font: int | None = None,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> tuple[str, bool]:
|
||||||
from util.wolfdawn import codes as wolf_codes
|
from util.wolfdawn import codes as wolf_codes
|
||||||
|
|
||||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||||
return text if isinstance(text, str) else "", False
|
return text if isinstance(text, str) else "", False
|
||||||
|
|
||||||
|
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||||
|
prefix, nameplate, body = split_nameplate_body(text, **plate_kw)
|
||||||
|
work = body if nameplate else text
|
||||||
|
if nameplate and not body.strip():
|
||||||
|
# Already wiped / nameplate-only - do not invent or further destroy.
|
||||||
|
return text, False
|
||||||
|
|
||||||
limit = int(max_lines) if max_lines is not None else 0
|
limit = int(max_lines) if max_lines is not None else 0
|
||||||
sizes = wolf_codes.detect_font_sizes(text)
|
sizes = wolf_codes.detect_font_sizes(work)
|
||||||
if box_font is not None and int(box_font) > 0:
|
if box_font is not None and int(box_font) > 0:
|
||||||
native = int(box_font)
|
native = int(box_font)
|
||||||
elif sizes:
|
elif sizes:
|
||||||
native = wolf_codes.infer_base_font_size(text)
|
native = wolf_codes.infer_base_font_size(work)
|
||||||
else:
|
else:
|
||||||
native = 18
|
native = 18
|
||||||
native = max(1, native)
|
native = max(1, native)
|
||||||
|
|
@ -680,28 +990,37 @@ def fit_text_to_box(
|
||||||
cur = max(1, int(current_font))
|
cur = max(1, int(current_font))
|
||||||
return max(1, int(round(width * native / cur)))
|
return max(1, int(round(width * native / cur)))
|
||||||
|
|
||||||
current = wolf_codes.infer_base_font_size(text, default=native) if sizes else native
|
current = wolf_codes.infer_base_font_size(work, default=native) if sizes else native
|
||||||
new_text = wrap_line_text(text, _effective_width(current) if sizes else width)
|
new_body = wrap_line_text(work, _effective_width(current) if sizes else width)
|
||||||
|
if nameplate and work.strip() and not new_body.strip():
|
||||||
|
return text, False
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
|
new_text = (
|
||||||
|
join_nameplate_body(prefix, nameplate, new_body) if nameplate else new_body
|
||||||
|
)
|
||||||
|
new_text = _reject_empty_body_result(text, new_text)
|
||||||
return new_text, new_text != text
|
return new_text, new_text != text
|
||||||
|
|
||||||
# No \\f yet and already over budget: introduce the native body size first.
|
if not sizes and count_soft_lines(new_body) > limit:
|
||||||
if not sizes and count_soft_lines(new_text) > limit:
|
new_body, _ = wolf_codes.scale_font_sizes(new_body, native)
|
||||||
new_text, _ = wolf_codes.scale_font_sizes(new_text, native)
|
|
||||||
current = native
|
current = native
|
||||||
new_text = wrap_line_text(new_text, _effective_width(current))
|
new_body = wrap_line_text(new_body, _effective_width(current))
|
||||||
|
|
||||||
guard = 0
|
guard = 0
|
||||||
while count_soft_lines(new_text) > limit and guard < 64:
|
while count_soft_lines(new_body) > limit and guard < 64:
|
||||||
guard += 1
|
guard += 1
|
||||||
current = wolf_codes.infer_base_font_size(new_text, default=current)
|
current = wolf_codes.infer_base_font_size(new_body, default=current)
|
||||||
if current <= min_font:
|
if current <= min_font:
|
||||||
break
|
break
|
||||||
target = current - 1
|
target = current - 1
|
||||||
new_text, _ = wolf_codes.scale_font_sizes(new_text, target)
|
new_body, _ = wolf_codes.scale_font_sizes(new_body, target)
|
||||||
current = target
|
current = target
|
||||||
new_text = wrap_line_text(new_text, _effective_width(current))
|
new_body = wrap_line_text(new_body, _effective_width(current))
|
||||||
|
|
||||||
|
if nameplate and work.strip() and not new_body.strip():
|
||||||
|
return text, False
|
||||||
|
new_text = join_nameplate_body(prefix, nameplate, new_body) if nameplate else new_body
|
||||||
|
new_text = _reject_empty_body_result(text, new_text)
|
||||||
return new_text, new_text != text
|
return new_text, new_text != text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -730,6 +1049,8 @@ def apply_relayout_to_line_dict(
|
||||||
max_lines=max_lines,
|
max_lines=max_lines,
|
||||||
min_font=min_font,
|
min_font=min_font,
|
||||||
box_font=native,
|
box_font=native,
|
||||||
|
speaker_src=str(line.get("speaker_src") or ""),
|
||||||
|
speaker=str(line.get("speaker") or ""),
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
return False
|
return False
|
||||||
|
|
@ -744,12 +1065,15 @@ def apply_manual_font_and_wrap(
|
||||||
font: int | None = None,
|
font: int | None = None,
|
||||||
old_base: int | None = None,
|
old_base: int | None = None,
|
||||||
only_overflow_or_fonts: bool = False,
|
only_overflow_or_fonts: bool = False,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
"""Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*.
|
"""Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*.
|
||||||
|
|
||||||
Always reflows when the wrap layout would differ from the current text
|
Always reflows when the wrap layout would differ from the current text
|
||||||
(same as the live preview). Soft ``\\n`` breaks that already "fit" per line
|
(same as the live preview). Soft ``\\n`` breaks that already "fit" per line
|
||||||
are collapsed and rebuilt so Manual Wrap matches what the preview shows.
|
are collapsed and rebuilt so Manual Wrap matches what the preview shows.
|
||||||
|
Nameplate lines (``Celria\\n…``) stay on their own first line.
|
||||||
|
|
||||||
When *only_overflow_or_fonts* is True (group wrap), skip short plain lines
|
When *only_overflow_or_fonts* is True (group wrap), skip short plain lines
|
||||||
that already match the target layout and have no ``\\f[N]``.
|
that already match the target layout and have no ``\\f[N]``.
|
||||||
|
|
@ -761,30 +1085,45 @@ def apply_manual_font_and_wrap(
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
return text, False
|
return text, False
|
||||||
|
|
||||||
has_f = bool(wolf_codes.detect_font_sizes(text))
|
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||||
overflows = width > 0 and line_needs_wrap(text, width)
|
prefix, nameplate, body = split_nameplate_body(text, **plate_kw)
|
||||||
wrapped_as_is = wrap_line_text(text, width) if width > 0 else text
|
work = body if nameplate else text
|
||||||
|
if nameplate and not body.strip():
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
has_f = bool(wolf_codes.detect_font_sizes(work))
|
||||||
|
overflows = width > 0 and line_needs_wrap(work, width)
|
||||||
|
wrapped_as_is = (
|
||||||
|
wrap_preserving_nameplate(text, width, **plate_kw) if width > 0 else text
|
||||||
|
)
|
||||||
layout_differs = width > 0 and wrapped_as_is != text
|
layout_differs = width > 0 and wrapped_as_is != text
|
||||||
|
|
||||||
if only_overflow_or_fonts and not has_f and not overflows and not layout_differs:
|
if only_overflow_or_fonts and not has_f and not overflows and not layout_differs:
|
||||||
return text, False
|
return text, False
|
||||||
|
|
||||||
new_text = text
|
new_work = work
|
||||||
apply_font = (
|
apply_font = (
|
||||||
font is not None
|
font is not None
|
||||||
and int(font) > 0
|
and int(font) > 0
|
||||||
and (has_f or overflows or layout_differs or not only_overflow_or_fonts)
|
and (has_f or overflows or layout_differs or not only_overflow_or_fonts)
|
||||||
)
|
)
|
||||||
if apply_font:
|
if apply_font:
|
||||||
new_text, _ = wolf_codes.scale_font_sizes(
|
new_work, _ = wolf_codes.scale_font_sizes(
|
||||||
new_text, int(font), old_base=old_base
|
new_work, int(font), old_base=old_base
|
||||||
)
|
)
|
||||||
|
|
||||||
if width > 0:
|
if width > 0:
|
||||||
wrapped = wrap_line_text(new_text, width)
|
wrapped = wrap_line_text(new_work, width)
|
||||||
if wrapped != new_text:
|
if wrapped != new_work:
|
||||||
new_text = wrapped
|
new_work = wrapped
|
||||||
|
|
||||||
|
if nameplate and work.strip() and not str(new_work).strip():
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
new_text = (
|
||||||
|
join_nameplate_body(prefix, nameplate, new_work) if nameplate else new_work
|
||||||
|
)
|
||||||
|
new_text = _reject_empty_body_result(text, new_text)
|
||||||
return new_text, new_text != text
|
return new_text, new_text != text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -803,7 +1142,13 @@ def wrap_hit_manual(
|
||||||
text = line.get("text")
|
text = line.get("text")
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
return False
|
return False
|
||||||
new_text, changed = apply_manual_font_and_wrap(text, width, font=font)
|
new_text, changed = apply_manual_font_and_wrap(
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
font=font,
|
||||||
|
speaker_src=str(line.get("speaker_src") or ""),
|
||||||
|
speaker=str(line.get("speaker") or ""),
|
||||||
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
return False
|
return False
|
||||||
line["text"] = new_text
|
line["text"] = new_text
|
||||||
|
|
@ -818,6 +1163,7 @@ def wrap_overflow_manual_in_scope(
|
||||||
width: int,
|
width: int,
|
||||||
*,
|
*,
|
||||||
font: int | None = None,
|
font: int | None = None,
|
||||||
|
translated_dir: Path | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Manual wrap (+ optional proportional font) for every line in *hit*'s group."""
|
"""Manual wrap (+ optional proportional font) for every line in *hit*'s group."""
|
||||||
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
||||||
|
|
@ -827,8 +1173,13 @@ def wrap_overflow_manual_in_scope(
|
||||||
if kind == "db":
|
if kind == "db":
|
||||||
return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font)
|
return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font)
|
||||||
if kind in ("map", "common"):
|
if kind in ("map", "common"):
|
||||||
return _wrap_manual_in_event_scene(
|
speaker_src = _hit_speaker_src(hit, doc)
|
||||||
path, doc, _hit_scene_index(hit), width, font=font
|
if translated_dir is not None:
|
||||||
|
return wrap_overflow_manual_in_dialogue_format(
|
||||||
|
translated_dir, speaker_src, width, font=font
|
||||||
|
)
|
||||||
|
return _wrap_manual_in_event_format(
|
||||||
|
path, doc, speaker_src, width, font=font
|
||||||
)
|
)
|
||||||
if kind == "gamedat":
|
if kind == "gamedat":
|
||||||
return _wrap_manual_in_gamedat(path, doc, width, font=font)
|
return _wrap_manual_in_gamedat(path, doc, width, font=font)
|
||||||
|
|
@ -845,7 +1196,12 @@ def _apply_manual_to_line_dict(
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
return False
|
return False
|
||||||
new_text, changed = apply_manual_font_and_wrap(
|
new_text, changed = apply_manual_font_and_wrap(
|
||||||
text, width, font=font, only_overflow_or_fonts=True
|
text,
|
||||||
|
width,
|
||||||
|
font=font,
|
||||||
|
only_overflow_or_fonts=True,
|
||||||
|
speaker_src=str(line.get("speaker_src") or ""),
|
||||||
|
speaker=str(line.get("speaker") or ""),
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
return False
|
return False
|
||||||
|
|
@ -898,29 +1254,52 @@ def _wrap_manual_in_db_sheet(
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def _wrap_manual_in_event_scene(
|
def _wrap_manual_in_event_format(
|
||||||
path: Path,
|
path: Path,
|
||||||
doc: dict[str, Any],
|
doc: dict[str, Any],
|
||||||
scene_index: int | None,
|
speaker_src: str,
|
||||||
width: int,
|
width: int,
|
||||||
*,
|
*,
|
||||||
font: int | None = None,
|
font: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Manual-wrap overflowing lines in one map/CommonEvent scene."""
|
"""Manual-wrap overflowing lines in one file that share *speaker_src*'s format."""
|
||||||
if doc.get("kind") not in ("map", "common"):
|
if doc.get("kind") not in ("map", "common"):
|
||||||
return 0
|
return 0
|
||||||
scenes = doc.get("scenes") or []
|
want = wrap_format_key(speaker_src)
|
||||||
if scene_index is None or not (0 <= scene_index < len(scenes)):
|
|
||||||
return 0
|
|
||||||
changed = 0
|
changed = 0
|
||||||
for line in scenes[scene_index].get("lines") or []:
|
for scene in doc.get("scenes") or []:
|
||||||
if _apply_manual_to_line_dict(line, width, font=font):
|
for line in scene.get("lines") or []:
|
||||||
changed += 1
|
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||||
|
continue
|
||||||
|
if _apply_manual_to_line_dict(line, width, font=font):
|
||||||
|
changed += 1
|
||||||
if changed:
|
if changed:
|
||||||
save_document(path, doc)
|
save_document(path, doc)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_overflow_manual_in_dialogue_format(
|
||||||
|
translated_dir: str | Path,
|
||||||
|
speaker_src: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
font: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Manual-wrap matching-format lines across all map/CommonEvent JSON."""
|
||||||
|
root = Path(translated_dir)
|
||||||
|
if not root.is_dir() or width <= 0:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
for path in sorted(root.glob("*.json")):
|
||||||
|
doc = _load_json(path)
|
||||||
|
if not isinstance(doc, dict) or doc.get("kind") not in ("map", "common"):
|
||||||
|
continue
|
||||||
|
total += _wrap_manual_in_event_format(
|
||||||
|
path, doc, speaker_src, width, font=font
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def _wrap_manual_in_gamedat(
|
def _wrap_manual_in_gamedat(
|
||||||
path: Path,
|
path: Path,
|
||||||
doc: dict[str, Any],
|
doc: dict[str, Any],
|
||||||
|
|
@ -968,8 +1347,24 @@ def _hit_scene_index(hit: WrapHit | dict[str, Any]) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hit_speaker_src(
|
||||||
|
hit: WrapHit | dict[str, Any],
|
||||||
|
doc: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Best-effort ``speaker_src`` for a wrap hit (from live line when possible)."""
|
||||||
|
if doc is not None:
|
||||||
|
hit_id = hit.hit_id if isinstance(hit, WrapHit) else hit
|
||||||
|
if isinstance(hit_id, dict):
|
||||||
|
line = locate_line(doc, hit_id)
|
||||||
|
if isinstance(line, dict) and line.get("speaker_src") is not None:
|
||||||
|
return str(line.get("speaker_src") or "")
|
||||||
|
if isinstance(hit, dict) and hit.get("speaker_src") is not None:
|
||||||
|
return str(hit.get("speaker_src") or "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _scene_group_label(doc: dict[str, Any] | None, hit: WrapHit | dict[str, Any]) -> str:
|
def _scene_group_label(doc: dict[str, Any] | None, hit: WrapHit | dict[str, Any]) -> str:
|
||||||
"""Label for one map/CommonEvent scene (the wrap-group unit)."""
|
"""Deprecated scene label; prefer format label for map/common groups."""
|
||||||
kind, sheet = _hit_kind_and_sheet(hit, doc)
|
kind, sheet = _hit_kind_and_sheet(hit, doc)
|
||||||
si = _hit_scene_index(hit)
|
si = _hit_scene_index(hit)
|
||||||
scene: dict[str, Any] | None = None
|
scene: dict[str, Any] | None = None
|
||||||
|
|
@ -1016,7 +1411,7 @@ def scope_label(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None
|
||||||
if kind == "gamedat":
|
if kind == "gamedat":
|
||||||
return "Game.dat"
|
return "Game.dat"
|
||||||
if kind in ("map", "common"):
|
if kind in ("map", "common"):
|
||||||
return _scene_group_label(doc, hit)
|
return wrap_format_label(_hit_speaker_src(hit, doc))
|
||||||
return sheet or jf
|
return sheet or jf
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1056,20 +1451,21 @@ def scope_stats(
|
||||||
if line_needs_wrap(text, width):
|
if line_needs_wrap(text, width):
|
||||||
overflow += 1
|
overflow += 1
|
||||||
elif kind in ("map", "common"):
|
elif kind in ("map", "common"):
|
||||||
si = _hit_scene_index(hit)
|
want = wrap_format_key(_hit_speaker_src(hit, doc))
|
||||||
scenes = doc.get("scenes") or []
|
for scene in doc.get("scenes") or []:
|
||||||
if si is not None and 0 <= si < len(scenes):
|
for line in scene.get("lines") or []:
|
||||||
scene_iter = [scenes[si]]
|
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||||
else:
|
continue
|
||||||
# No scene on the hit: fall back to whole file (legacy).
|
|
||||||
scene_iter = scenes
|
|
||||||
for scene in scene_iter:
|
|
||||||
for line in (scene.get("lines") or []):
|
|
||||||
text = line.get("text")
|
text = line.get("text")
|
||||||
if not isinstance(text, str) or not text.strip():
|
if not isinstance(text, str) or not text.strip():
|
||||||
continue
|
continue
|
||||||
total += 1
|
total += 1
|
||||||
if line_needs_wrap(text, width):
|
if line_needs_wrap_preserving_nameplate(
|
||||||
|
text,
|
||||||
|
width,
|
||||||
|
speaker_src=str(line.get("speaker_src") or ""),
|
||||||
|
speaker=str(line.get("speaker") or ""),
|
||||||
|
):
|
||||||
overflow += 1
|
overflow += 1
|
||||||
elif kind == "gamedat":
|
elif kind == "gamedat":
|
||||||
for line in doc.get("lines") or []:
|
for line in doc.get("lines") or []:
|
||||||
|
|
@ -1085,45 +1481,82 @@ def scope_stats(
|
||||||
return ScopeStats(label=scope_label(hit, doc), total=total, overflow=overflow)
|
return ScopeStats(label=scope_label(hit, doc), total=total, overflow=overflow)
|
||||||
|
|
||||||
|
|
||||||
def _line_needs_relayout(text: str, width: int, max_lines: int | None) -> bool:
|
def _line_needs_relayout(
|
||||||
|
text: str,
|
||||||
|
width: int,
|
||||||
|
max_lines: int | None,
|
||||||
|
*,
|
||||||
|
speaker_src: str = "",
|
||||||
|
speaker: str = "",
|
||||||
|
) -> bool:
|
||||||
"""True if width wrap or max-lines fit would change *text*."""
|
"""True if width wrap or max-lines fit would change *text*."""
|
||||||
if line_needs_wrap(text, width):
|
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||||
|
if line_needs_wrap_preserving_nameplate(text, width, **plate_kw):
|
||||||
return True
|
return True
|
||||||
limit = int(max_lines) if max_lines is not None else 0
|
limit = int(max_lines) if max_lines is not None else 0
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return False
|
return False
|
||||||
wrapped = wrap_line_text(text, width)
|
wrapped = wrap_preserving_nameplate(text, width, **plate_kw)
|
||||||
return count_soft_lines(wrapped) > limit or wrapped != text
|
return (
|
||||||
|
count_body_soft_lines(wrapped, **plate_kw) > limit or wrapped != text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wrap_overflow_in_event_scene(
|
def _wrap_overflow_in_event_format(
|
||||||
path: Path,
|
path: Path,
|
||||||
doc: dict[str, Any],
|
doc: dict[str, Any],
|
||||||
scene_index: int | None,
|
speaker_src: str,
|
||||||
width: int,
|
width: int,
|
||||||
*,
|
*,
|
||||||
max_lines: int | None = None,
|
max_lines: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Wrap overflowing dialogue lines in one map or CommonEvent scene."""
|
"""Wrap overflowing lines in one file that share *speaker_src*'s format."""
|
||||||
if doc.get("kind") not in ("map", "common"):
|
if doc.get("kind") not in ("map", "common"):
|
||||||
return 0
|
return 0
|
||||||
scenes = doc.get("scenes") or []
|
want = wrap_format_key(speaker_src)
|
||||||
if scene_index is None or not (0 <= scene_index < len(scenes)):
|
|
||||||
return 0
|
|
||||||
changed = 0
|
changed = 0
|
||||||
for line in scenes[scene_index].get("lines") or []:
|
for scene in doc.get("scenes") or []:
|
||||||
text = line.get("text")
|
for line in scene.get("lines") or []:
|
||||||
if not isinstance(text, str) or not text.strip():
|
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||||
continue
|
continue
|
||||||
if not _line_needs_relayout(text, width, max_lines):
|
text = line.get("text")
|
||||||
continue
|
if not isinstance(text, str) or not text.strip():
|
||||||
if apply_relayout_to_line_dict(line, width, max_lines=max_lines):
|
continue
|
||||||
changed += 1
|
src = str(line.get("speaker_src") or "")
|
||||||
|
spk = str(line.get("speaker") or "")
|
||||||
|
if not _line_needs_relayout(
|
||||||
|
text, width, max_lines, speaker_src=src, speaker=spk
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if apply_relayout_to_line_dict(line, width, max_lines=max_lines):
|
||||||
|
changed += 1
|
||||||
if changed:
|
if changed:
|
||||||
save_document(path, doc)
|
save_document(path, doc)
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_overflow_in_dialogue_format(
|
||||||
|
translated_dir: str | Path,
|
||||||
|
speaker_src: str,
|
||||||
|
width: int,
|
||||||
|
*,
|
||||||
|
max_lines: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Relayout matching-format lines across all map/CommonEvent JSON."""
|
||||||
|
root = Path(translated_dir)
|
||||||
|
if not root.is_dir() or width <= 0:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
for path in sorted(root.glob("*.json")):
|
||||||
|
doc = _load_json(path)
|
||||||
|
if not isinstance(doc, dict) or doc.get("kind") not in ("map", "common"):
|
||||||
|
continue
|
||||||
|
total += _wrap_overflow_in_event_format(
|
||||||
|
path, doc, speaker_src, width, max_lines=max_lines
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def _wrap_overflow_in_gamedat(
|
def _wrap_overflow_in_gamedat(
|
||||||
path: Path,
|
path: Path,
|
||||||
doc: dict[str, Any],
|
doc: dict[str, Any],
|
||||||
|
|
@ -1156,18 +1589,17 @@ def wrap_overflow_in_scope(
|
||||||
width: int,
|
width: int,
|
||||||
*,
|
*,
|
||||||
max_lines: int | None = None,
|
max_lines: int | None = None,
|
||||||
|
translated_dir: Path | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Wrap overflowing lines in the same group/file as *hit*.
|
"""Wrap overflowing lines in the same group as *hit*.
|
||||||
|
|
||||||
For DB / map / common / gamedat, *max_lines* also shrinks ``\\f[N]`` until
|
For DB / gamedat, *max_lines* also shrinks ``\\f[N]`` until the soft line
|
||||||
the soft line count fits (Relayout). Names categories ignore *max_lines*
|
count fits. For map / CommonEvent, the group is the ``speaker_src`` format
|
||||||
|
bucket (spoken / ui / …); when *translated_dir* is set, every map and
|
||||||
|
CommonEvent JSON under it is included. Names categories ignore *max_lines*
|
||||||
here - use ``wolf names-wrap`` via the GUI instead.
|
here - use ``wolf names-wrap`` via the GUI instead.
|
||||||
"""
|
"""
|
||||||
if isinstance(hit, WrapHit):
|
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
||||||
kind, sheet_name = hit.kind, hit.sheet_name
|
|
||||||
else:
|
|
||||||
kind = str(hit.get("kind") or doc.get("kind") or "")
|
|
||||||
sheet_name = str(hit.get("sheet_name") or "")
|
|
||||||
|
|
||||||
if kind == "names":
|
if kind == "names":
|
||||||
return _wrap_overflow_in_names_category(path, doc, sheet_name, width)
|
return _wrap_overflow_in_names_category(path, doc, sheet_name, width)
|
||||||
|
|
@ -1176,8 +1608,13 @@ def wrap_overflow_in_scope(
|
||||||
path, doc, sheet_name, width, kind="db", max_lines=max_lines
|
path, doc, sheet_name, width, kind="db", max_lines=max_lines
|
||||||
)
|
)
|
||||||
if kind in ("map", "common"):
|
if kind in ("map", "common"):
|
||||||
return _wrap_overflow_in_event_scene(
|
speaker_src = _hit_speaker_src(hit, doc)
|
||||||
path, doc, _hit_scene_index(hit), width, max_lines=max_lines
|
if translated_dir is not None:
|
||||||
|
return wrap_overflow_in_dialogue_format(
|
||||||
|
translated_dir, speaker_src, width, max_lines=max_lines
|
||||||
|
)
|
||||||
|
return _wrap_overflow_in_event_format(
|
||||||
|
path, doc, speaker_src, width, max_lines=max_lines
|
||||||
)
|
)
|
||||||
if kind == "gamedat":
|
if kind == "gamedat":
|
||||||
return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines)
|
return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue