2121from ezdxf .addons import odafc
2222from ezdxf .addons .drawing import Frontend , RenderContext , config , layout , pymupdf
2323from ezdxf .enums import TextEntityAlignment
24+ from ezdxf .fonts import fonts as ezfonts
2425from ezdxf .tools .text import MTextEditor
2526
2627from upload import upload_file
2728
2829logger = 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.
3141PAPER_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