Skip to content

Commit 6b2cef9

Browse files
rajman01claude
andcommitted
Fix justified text placement in DWG output
AutoCAD draws DWG TEXT glyphs from the insertion point (group 10) and keeps the alignment point (group 11) as editing metadata, while the ODA converter copies both points through unchanged. ezdxf leaves the insertion point equal to the alignment point, so every centered/right/ top justified label (plan number, profile table labels, chainage labels) rendered as left/baseline justified once converted to DWG, while the PDF (rendered from the alignment point) looked correct. Recompute the true baseline-left insertion point for all justified TEXT entities before saving, using the style's font metrics. Install fonts-liberation in the Docker image and measure with metric-compatible Liberation faces when the style's real font is not available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2113332 commit 6b2cef9

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ RUN apt-get update && apt-get install -y \
77
xvfb \
88
libc6 \
99
libfontconfig1 \
10+
fonts-liberation \
1011
&& rm -rf /var/lib/apt/lists/*
1112

1213
# Download ODA File Converter AppImage (replace with the latest version)

dxf_manager.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@
2121
from ezdxf.addons import odafc
2222
from ezdxf.addons.drawing import Frontend, RenderContext, config, layout, pymupdf
2323
from ezdxf.enums import TextEntityAlignment
24+
from ezdxf.fonts import fonts as ezfonts
2425
from ezdxf.tools.text import MTextEditor
2526

2627
from upload import upload_file
2728

2829
logger = logging.getLogger(__name__)
2930

31+
# Metric-compatible substitutes used to *measure* text when the style's real
32+
# font is not installed (e.g. in the Docker container). The DXF still names
33+
# the original font; only the width/height estimates use the substitute.
34+
MEASUREMENT_FONT_SUBSTITUTES = {
35+
"times new roman": "LiberationSerif-Regular.ttf",
36+
"arial": "LiberationSans-Regular.ttf",
37+
"courier new": "LiberationMono-Regular.ttf",
38+
}
39+
3040
# Paper sizes in mm (width, height) for portrait orientation.
3141
PAPER_SIZES = {
3242
"A0": (841, 1189),
@@ -636,6 +646,89 @@ def toggle_layer(self, layer: str, state: bool):
636646
else:
637647
layer_entity.off()
638648

649+
def _measurement_font(self, font_name: str, cap_height: float):
650+
"""Font used to estimate text extents, cached per (font, height).
651+
652+
Falls back to a metric-compatible substitute when the style's font is
653+
not installed, so estimated widths stay close to what CAD software
654+
with the real font will render.
655+
"""
656+
cache = getattr(self, "_font_cache", None)
657+
if cache is None:
658+
cache = self._font_cache = {}
659+
key = (font_name, round(cap_height, 9))
660+
font = cache.get(key)
661+
if font is None:
662+
name = font_name
663+
face = ezfonts.font_manager.get_font_face(name)
664+
stem = os.path.splitext(os.path.basename(font_name))[0].lower()
665+
if face is not None and stem not in face.family.lower():
666+
substitute = MEASUREMENT_FONT_SUBSTITUTES.get(stem)
667+
if substitute is not None:
668+
sub_face = ezfonts.font_manager.get_font_face(substitute)
669+
if sub_face is not None and "liberation" in sub_face.family.lower():
670+
name = substitute
671+
font = cache[key] = ezfonts.make_font(name, cap_height)
672+
return font
673+
674+
def fix_justified_text_insert_points(self):
675+
"""Recompute the baseline-left insertion point of justified TEXT.
676+
677+
AutoCAD draws DWG TEXT glyphs starting at the insertion point
678+
(group 10) and keeps the alignment point (group 11) as editing
679+
metadata, while the ODA converter copies both points through
680+
unchanged. ezdxf leaves the insertion point equal to the alignment
681+
point (the DXF reference allows this because DXF readers must use
682+
the alignment point), so every centered/right/top justified label
683+
rendered as left/baseline justified once converted to DWG.
684+
"""
685+
spaces = [self.msp] + [block for block in self.doc.blocks]
686+
for space in spaces:
687+
for text in space.query("TEXT"):
688+
halign = text.dxf.halign
689+
valign = text.dxf.valign
690+
if (halign == 0 and valign == 0) or halign in (3, 5):
691+
# baseline-left already, or ALIGNED/FIT dual-point modes
692+
continue
693+
if not text.dxf.hasattr("align_point"):
694+
continue
695+
696+
style_name = text.dxf.style
697+
font_file = "txt"
698+
if style_name in self.doc.styles:
699+
font_file = self.doc.styles.get(style_name).dxf.font or "txt"
700+
cap_height = text.dxf.height
701+
font = self._measurement_font(font_file, cap_height)
702+
703+
width = font.text_width(text.dxf.text) * text.dxf.width
704+
m = font.measurements
705+
706+
dx = 0.0
707+
if halign in (1, 4): # center / middle
708+
dx = -width / 2
709+
elif halign == 2: # right
710+
dx = -width
711+
712+
if halign == 4: # MIDDLE: centered on the full glyph extent
713+
dy = (m.descender_height - m.cap_height) / 2
714+
elif valign == 1: # bottom (descender line)
715+
dy = m.descender_height
716+
elif valign == 2: # middle of capitals
717+
dy = -m.cap_height / 2
718+
elif valign == 3: # top of capitals
719+
dy = -m.cap_height
720+
else: # baseline
721+
dy = 0.0
722+
723+
rot = math.radians(text.dxf.rotation)
724+
cos_r, sin_r = math.cos(rot), math.sin(rot)
725+
align = text.dxf.align_point
726+
text.dxf.insert = (
727+
align.x + dx * cos_r - dy * sin_r,
728+
align.y + dx * sin_r + dy * cos_r,
729+
align.z,
730+
)
731+
639732
def get_filename(self) -> str:
640733
plan_name = self.plan_name.lower()
641734
plan_name = re.sub(r"\s+", "_", plan_name)
@@ -646,6 +739,7 @@ def get_filename(self) -> str:
646739
def save_dxf(self, filepath: Optional[str] = None):
647740
if not filepath:
648741
filepath = f"{self.get_filename()}.dxf"
742+
self.fix_justified_text_insert_points()
649743
self.doc.saveas(filepath)
650744

651745
def save_pdf(self, filepath: Optional[str] = None, paper_size: str = "A4", orientation: str = "portrait"):

0 commit comments

Comments
 (0)