ea 0.6.120 → 0.6.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,9 @@ module Ea
51
51
  # Advance used for codepoints missing from the tables.
52
52
  DEFAULT_ADVANCE = 0.25
53
53
 
54
+ # Inferred subpixel steps that reproduce EA's near-half-pixel rounding.
55
+ GDI_SUBPIXELS = 64
56
+
54
57
  # Carlito/Arial have no CJK glyphs; EA's renderer falls back to
55
58
  # a proportional CJK font whose advances measure ~0.709em at
56
59
  # the NOMINAL size (not scaled by the per-style factors) —
@@ -89,6 +92,13 @@ module Ea
89
92
  (size.to_f * 10 / 7).round
90
93
  end
91
94
 
95
+ # Quantize before rounding: near-half-pixel Carlito advances
96
+ # round up in EA's output. The 1/64px step is inferred.
97
+ def gdi_pixel_advance(advance, height)
98
+ ((advance * height * GDI_SUBPIXELS).round / GDI_SUBPIXELS.to_f).round
99
+ end
100
+ private_class_method :gdi_pixel_advance
101
+
92
102
  def compact_compartment_font?(size, family:, unit: "pt")
93
103
  family.to_s.casecmp("Cambria").zero? && size == 7 && unit == "pt"
94
104
  end
@@ -122,7 +132,7 @@ module Ea
122
132
  if cjk?(ch.ord)
123
133
  cjk_count += 1
124
134
  else
125
- latin += ((table[cp_key(ch)] || DEFAULT_ADVANCE) * height).round
135
+ latin += gdi_pixel_advance(table[cp_key(ch)] || DEFAULT_ADVANCE, height)
126
136
  end
127
137
  end
128
138
  return latin + cjk_count * CJK_ADVANCE * size_pt.to_f
@@ -166,7 +176,7 @@ module Ea
166
176
 
167
177
  # Families whose EA driver quantizes per-glyph advances
168
178
  # (fitted from published textLengths).
169
- GDI_HEIGHT_FAMILIES = %w[Carlito].freeze
179
+ GDI_HEIGHT_FAMILIES = %w[Carlito Arial].freeze
170
180
 
171
181
  def supported_family?(family)
172
182
  !resolve_family(family).nil?
@@ -22,6 +22,8 @@ module Ea
22
22
  attribute :line_width, :integer
23
23
  attribute :font_family, :string
24
24
  attribute :font_size, :integer
25
+ # nil: unspecified provenance; true: source delegates to default resolution.
26
+ attribute :font_size_inherited, :boolean
25
27
  attribute :font_logical_height, :integer # original GDI pixel height before point normalization
26
28
  attribute :font_size_unit, :string # source font size unit, before theme fallback
27
29
  attribute :font_bold, :boolean
@@ -55,6 +57,7 @@ module Ea
55
57
  map "lineWidth", to: :line_width
56
58
  map "fontFamily", to: :font_family
57
59
  map "fontSize", to: :font_size
60
+ map "fontSizeInherited", to: :font_size_inherited, render_default: true
58
61
  map "fontSizeUnit", to: :font_size_unit
59
62
  map "fontLogicalHeight", to: :font_logical_height
60
63
  map "fontBold", to: :font_bold, render_default: true
@@ -138,6 +138,7 @@ module Ea
138
138
  style: style_hash,
139
139
  font_family: presence(font_style[:font]),
140
140
  font_size: font_size_from(font_style),
141
+ font_size_inherited: font_size_from(font_style).nil?,
141
142
  font_size_unit: "pt",
142
143
  font_logical_height: font_logical_height_from(font_style),
143
144
  font_bold: truthy?(style_hash[:bold]),
@@ -150,14 +151,14 @@ module Ea
150
151
  )
151
152
  end
152
153
 
153
- # Negative widths inherit the saved object border, then the diagram theme.
154
+ # Negative widths inherit; QEA zero widths request a one-pixel cosmetic pen.
154
155
  def effective_line_width(obj_row, style)
155
156
  object = database.find_object(obj_row.ea_object_id)
156
157
  [style[:lwth], object&.borderwidth].each do |raw|
157
158
  next if raw.nil? || raw.to_s.empty?
158
159
 
159
160
  width = raw.to_i
160
- return width if width >= 0
161
+ return [width, 1].max if width >= 0
161
162
  end
162
163
  nil
163
164
  end
@@ -6,7 +6,7 @@ module Ea
6
6
  # Single source of truth for fill + stroke color decisions.
7
7
  #
8
8
  # Theme :119 applies:
9
- # - Border color (#9A8484) for element strokes
9
+ # - Black default element strokes
10
10
  # - Method color for operation text
11
11
  # Theme :119 does NOT apply:
12
12
  # - Per-type fill colors (elements use default #FFFFFF)
@@ -21,13 +21,8 @@ module Ea
21
21
  %(<g style="#{group_style(context)}">\n#{text_blocks.join("\n")}\n</g>)
22
22
  end
23
23
 
24
- # EA wraps note text by MEASURED width (Carlito advances),
25
- # not character count: lines break at the last word whose
26
- # cumulative integer textLength fits bounds.width - 15.
27
- # Verified against EA-published reference SVGs.
28
24
  def wrapped_lines(context, body)
29
- # EA's note wrap padding is 4: lines fit bounds.width - 4
30
- # (9BA1CE54: width 214, longest line tl 210). Pitch and
25
+ # Lines fit bounds.width - 4. Pitch and
31
26
  # first-line offset scale with the note font (9pt: pitch
32
27
  # 16 = size+7, first line at bounds.y + 18 = size+9), and
33
28
  # each paragraph break adds one blank pitch row
@@ -41,7 +36,8 @@ module Ea
41
36
  y_index = 0
42
37
  paragraphs = NoteHtml.paragraphs(body)
43
38
  paragraphs.flat_map do |para|
44
- lines = wrap_paragraph(para.text, usable, size, context.family)
39
+ lines = wrap_paragraph(para.text, usable, size, context.family,
40
+ logical_height: context.logical_height)
45
41
  paragraph_svg = lines.each_with_index.map do |line, i|
46
42
  y = context.bounds.y + first_offset + ((y_index + i) * pitch)
47
43
  TextRenderer.new(
@@ -50,6 +46,7 @@ module Ea
50
46
  y: y,
51
47
  family: context.family, size: context.size,
52
48
  size_unit: context.size_unit,
49
+ logical_height: context.logical_height,
53
50
  fill: context.theme.text_color,
54
51
  stroke_in_text: context.theme.stroke_in_text_color,
55
52
  width_factor: context.theme.text_width_factor
@@ -60,14 +57,18 @@ module Ea
60
57
  end
61
58
  end
62
59
 
63
- def wrap_paragraph(para, usable, size, family)
64
- words = para.strip.split(/\s+/)
60
+ # Wrap at whitespace until native script-specific break metrics are
61
+ # established. Every word but the paragraph's last carries its
62
+ # trailing space into the fit test.
63
+ def wrap_paragraph(para, usable, size, family, logical_height: nil)
65
64
  lines = []
66
65
  current = ""
67
- words.each do |word|
66
+ words = para.strip.split(/\s+/)
67
+ words.each_with_index do |word, index|
68
68
  candidate = current.empty? ? word : "#{current} #{word}"
69
- width = TextRenderer.estimate_width(candidate, size, nil,
70
- family: family).round
69
+ trailing = index < words.size - 1 ? " " : ""
70
+ width = TextRenderer.estimate_width("#{candidate}#{trailing}", size, nil,
71
+ family: family, logical_height: logical_height).round
71
72
  if current.empty? || width <= usable
72
73
  current = candidate
73
74
  else
@@ -15,7 +15,7 @@ module Ea
15
15
  context.bounds,
16
16
  legend: context.model_element.legend,
17
17
  family: context.family,
18
- item_font: { size: context.element&.font_size&.nonzero?, unit: context.size_unit,
18
+ item_font: { size: legend_item_size(context), unit: context.size_unit,
19
19
  logical_height: context.logical_height }
20
20
  )
21
21
  elsif context.model_element.is_a?(Ea::Model::Note)
@@ -54,6 +54,17 @@ module Ea
54
54
  end
55
55
  end
56
56
 
57
+ def legend_item_size(context)
58
+ element = context.element
59
+ return unless element
60
+ return element.font_size if element.font_size&.nonzero?
61
+
62
+ # Parsed source defaults inherit; unspecified callers keep 9pt.
63
+ inherited = element.font_size_inherited || element.style&.fetch(:fontsz, nil).to_s == "0"
64
+ context.size if inherited
65
+ end
66
+ private_class_method :legend_item_size
67
+
57
68
  # A Text element with LegendOpts= renders as the legend
58
69
  # block rather than a folder silhouette. Dispatch on the
59
70
  # presence of the attached Legend payload.
@@ -233,7 +233,8 @@ module Ea
233
233
  private_class_method :facing_coordinate
234
234
 
235
235
  def stroke_outline?(connector, bounds)
236
- (connector.style.to_h[:lwidth] == "2" || default_model_connector?(connector)) &&
236
+ (connector.style.to_h[:lwidth] == "2" || default_model_connector?(connector) ||
237
+ connector.connector_type == "NoteLink") &&
237
238
  bounds.all? { |box| box.width.positive? && box.height.positive? } &&
238
239
  outline_connector?(connector, bounds)
239
240
  end
@@ -311,7 +312,7 @@ module Ea
311
312
 
312
313
  def outline_connector?(connector, bounds)
313
314
  return true if default_model_connector?(connector)
314
- return true if %w[Generalization Aggregation].include?(connector.connector_type)
315
+ return true if %w[Generalization Aggregation NoteLink].include?(connector.connector_type)
315
316
 
316
317
  return true if association_ray_outline?(connector)
317
318
  return false unless stored_association?(connector) || offset_free_association?(connector)
@@ -31,22 +31,39 @@ module Ea
31
31
  @divider_y_by_ref = divider_y_by_ref
32
32
  end
33
33
 
34
+ # EA draws each connector (and its markers) with the pen width
35
+ # stored in the diagram link style (`LWidth`); links without
36
+ # the field keep the diagram default.
37
+ def self.stroke_width_for(connector, default)
38
+ style = connector.style || {}
39
+ raw = style[:lwidth] || style["lwidth"]
40
+ return default if raw.to_s.strip.empty?
41
+
42
+ [raw.to_i, 1].max
43
+ end
44
+
45
+ # Layer key for a style group: the plain key at the default
46
+ # width, suffixed with the width otherwise so layers of
47
+ # different widths never merge.
48
+ def self.layer_key(key, width, default)
49
+ width == default ? key : :"#{key}_w#{width}"
50
+ end
51
+
34
52
  def layers
35
- paths = visible_connectors.filter_map { |c| path_for(c) }
36
- return [] if paths.empty?
53
+ styles = paths_with_styles
37
54
 
38
55
  if grouped
39
- [Layer.new(style_key: LINE_STYLE_KEY, style: line_style,
40
- body: paths.join("\n "))]
41
- else
42
- paths.map do |path|
43
- Layer.new(style_key: LINE_STYLE_KEY, style: line_style, body: path)
56
+ styles.group_by(&:first).map do |(width, dashed), entries|
57
+ line_layer(width, dashed, entries.map(&:last).join("\n "))
44
58
  end
59
+ else
60
+ styles.map { |(width, dashed), path| line_layer(width, dashed, path) }
45
61
  end
46
62
  end
47
63
 
48
- def line_style
49
- "stroke-width:#{stroke_width};stroke-linecap:round;stroke-linejoin:bevel; fill:#000000;fill-opacity:0.00; stroke:#000000; stroke-opacity:1.00"
64
+ def line_style(width = stroke_width)
65
+ "stroke-width:#{width};stroke-linecap:round;stroke-linejoin:bevel; " \
66
+ "fill:#000000;fill-opacity:0.00; stroke:#000000; stroke-opacity:1.00"
50
67
  end
51
68
 
52
69
  # Backwards-compatible render — joins all layers' `<g>`.
@@ -56,6 +73,31 @@ module Ea
56
73
 
57
74
  private
58
75
 
76
+ def paths_with_styles
77
+ visible_connectors.filter_map do |connector|
78
+ path = path_for(connector)
79
+ next unless path
80
+
81
+ [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)], path]
82
+ end
83
+ end
84
+
85
+ def line_layer(width, dashed, body)
86
+ key = dashed ? :connector_line_dashed : LINE_STYLE_KEY
87
+ style = line_style(width)
88
+ style += ";stroke-dasharray:6,3" if dashed
89
+ Layer.new(style_key: self.class.layer_key(key, width, stroke_width),
90
+ style: style, body: body)
91
+ end
92
+
93
+ def dashed?(connector)
94
+ return true if connector.connector_type == "Dependency"
95
+ return false unless connector.connector_type == "Package"
96
+
97
+ relationship = model_index && model_index[connector.relationship_ref]
98
+ relationship&.stereotype == "import"
99
+ end
100
+
59
101
  def visible_connectors
60
102
  (diagram.connectors || []).select(&:renderable?)
61
103
  end
@@ -10,13 +10,12 @@ module Ea
10
10
  # type.
11
11
  #
12
12
  # Layout (element bounds cover tab + body together; the tab
13
- # occupies the TOP of the stored rect — corpus-verified
14
- # against EA-published package polygons, single tab 20px and
15
- # stereotype tab 36px, both inside):
13
+ # occupies the TOP of the stored rect, with its height adjusted
14
+ # by the font line height for each header row):
16
15
  #
17
16
  # ┌───┐
18
- # │tab│ ← top TAB_HEIGHT rows of the bounds;
19
- # │ │ 36px when a stereotype is present
17
+ # │tab│ ← one name row, or two rows with a stereotype
18
+ # │ │
20
19
  # ├───┴──────────┐
21
20
  # │ │ the name renders bold, left-aligned;
22
21
  # │ body │ the body always starts where the
@@ -148,7 +147,11 @@ module Ea
148
147
  end
149
148
 
150
149
  def tab_height
151
- has_stereotype? ? TAB_HEIGHT_DOUBLE : TAB_HEIGHT_SINGLE
150
+ nominal_height = has_stereotype? ? TAB_HEIGHT_DOUBLE : TAB_HEIGHT_SINGLE
151
+ rows = has_stereotype? ? 2 : 1
152
+ # Preserve the nominal padding while each row follows the same
153
+ # font line height used to place the package header text.
154
+ nominal_height + rows * (tab_line_height - TAB_LABEL_Y_OFFSET)
152
155
  end
153
156
 
154
157
  def body_points
@@ -9,32 +9,15 @@ module Ea
9
9
  # chevron) when a stereotype provides a custom icon via
10
10
  # ShapeScript.
11
11
  #
12
- # Currently provides hardcoded fallback polygons for the most
13
- # common GML stereotypes (FeatureType, Type) since the source
14
- # ShapeScript definitions live in EA's encrypted
15
- # InternalTechnologies (TODO.diagrams/87). Future enhancement:
16
- # read shapescript from MDG files when available.
12
+ # Only MDG-provided ShapeScript is rendered.
17
13
  class StereotypeIconRenderer
18
- # Hardcoded fallback icons keyed by stereotype name.
19
- # Each entry is [fill, stroke, points_array].
20
- FALLBACK_ICONS = {
21
- "FeatureType" => ["#FAF1EC", "#69738C",
22
- [[0, -5], [5, 0], [0, 5], [-5, 0]]],
23
- "Type" => ["#FAF1EC", "#69738C",
24
- [[0, -5], [5, 0], [0, 5], [-5, 0]]]
25
- }.freeze
26
-
27
- # Default position offset from element's center
28
- OFFSET_X = 0
29
- OFFSET_Y = 0
30
-
31
14
  attr_reader :classifier, :bounds, :canvas, :mdg_registry
32
15
 
33
16
  # @param mdg_registry [Ea::Mdg::Registry, nil] optional
34
17
  # registry to look up MDG-provided ShapeScript for the
35
18
  # classifier's stereotype. When provided and the matching
36
19
  # stereotype has a ShapeScript body, it's parsed and
37
- # rendered. Otherwise falls back to FALLBACK_ICONS.
20
+ # rendered.
38
21
  def initialize(classifier:, bounds:, canvas: nil, mdg_registry: nil)
39
22
  @classifier = classifier
40
23
  @bounds = bounds
@@ -51,12 +34,12 @@ module Ea
51
34
  return "" unless classifier && bounds
52
35
  return "" unless stereotype_name
53
36
 
54
- shapescript_svg || fallback_svg
37
+ shapescript_svg.to_s
55
38
  end
56
39
 
57
40
  private
58
41
 
59
- # Try MDG-provided ShapeScript first.
42
+ # Renders MDG-provided ShapeScript for the stereotype.
60
43
  # @return [String, nil] SVG fragment from parsed ShapeScript
61
44
  def shapescript_svg
62
45
  return nil unless mdg_registry
@@ -83,35 +66,12 @@ module Ea
83
66
  nil
84
67
  end
85
68
 
86
- # @return [String] hardcoded fallback polygon, or "" if none
87
- def fallback_svg
88
- spec = FALLBACK_ICONS[stereotype_name]
89
- return "" unless spec
90
-
91
- fill, stroke, points = spec
92
- cx = bounds.x + bounds.width / 2 + OFFSET_X
93
- cy = bounds.y + bounds.height / 2 + OFFSET_Y
94
- translated = points.map { |x, y| translate_point(cx + x, cy + y) }
95
- pts_str = translated.map { |x, y| coord(x) + "," + coord(y) }.join(" ")
96
- %(<polygon points="#{pts_str}" fill="#{fill}" stroke="#{stroke}" stroke-width="1"/>)
97
- end
98
-
99
69
  def stereotype_name
100
70
  return nil unless classifier.is_a?(Ea::Model::Classifier)
101
71
 
102
72
  refs = classifier.stereotype_refs
103
73
  refs&.first
104
74
  end
105
-
106
- def translate_point(x, y)
107
- return [x, y] unless canvas
108
-
109
- [canvas.translate_x(x), canvas.translate_y(y)]
110
- end
111
-
112
- def coord(value)
113
- Ea::Svg::EaEmitter::Canvas.coord(value)
114
- end
115
75
  end
116
76
  end
117
77
  end
@@ -11,7 +11,7 @@ module Ea
11
11
  class Elements
12
12
  DEFAULT_FILL = "#FFFFFF"
13
13
  DEFAULT_STROKE = "#000000"
14
- DEFAULT_STROKE_WIDTH = 2
14
+ DEFAULT_STROKE_WIDTH = 1
15
15
  DEFAULT_TEXT_COLOR = "#000000"
16
16
 
17
17
  attr_reader :diagram, :model_index, :canvas, :document
@@ -152,8 +152,10 @@ module Ea
152
152
  []
153
153
  end
154
154
  # Titles wrap; content rows grow the drawn box to fit their insets.
155
- row_w = [row_content_width(attr_lines, 5, size, family, logical_height),
156
- row_content_width(op_lines, 3, size, family, logical_height),
155
+ row_w = [row_content_width(attr_lines, 5, size, family,
156
+ logical_height: logical_height,
157
+ content_x_offset: attribute_content_x_offset(size, family, size_unit)),
158
+ row_content_width(op_lines, 3, size, family, logical_height: logical_height),
157
159
  Compartment::EnumLiterals.content_width(
158
160
  enum_literals_for(classifier),
159
161
  theme: theme, size: size, family: family,
@@ -178,6 +180,9 @@ module Ea
178
180
  off_canvas_parent_name: parent_name,
179
181
  foreign_package_name: foreign_package_name_for(classifier),
180
182
  suppress_stereotypes: suppress_stereotypes?) : []
183
+ header_lines = header_lines.map do |text, style|
184
+ [text, font_resolver.header_style_for(style, element)]
185
+ end
181
186
  geometry = compartment_geometry(bounds, size, attr_lines, op_lines,
182
187
  tagged_values_for(classifier).size,
183
188
  header_lines, classifier: classifier, family: family,
@@ -246,7 +251,8 @@ module Ea
246
251
  # Real attributes grow the DRAWN box past the stored
247
252
  # height instead of being dropped (0CABA7D7: stored h44
248
253
  # draws at h59). Grown height = last row baseline + EA's
249
- # ~8px bottom padding. Render-local: the diagram-level
254
+ # trailing padding (7px for compact Cambria, 8px otherwise).
255
+ # Render-local: the diagram-level
250
256
  # canvas still derives from stored bounds.
251
257
  # content_bottom_y is nil when no compartment has rows;
252
258
  # op_bottom_y is NOT a sentinel (it reports the op-divider
@@ -257,13 +263,15 @@ module Ea
257
263
  # Bag': Set enum box stored h59 -> EA 223 fitting six
258
264
  # literals); op-only boxes keep the no-growth behavior
259
265
  # (op_bottom_y is a divider sentinel, not content).
266
+ # Saved boxes may already fit rows with less than the growth
267
+ # padding. Test the baseline for overflow before adding padding.
260
268
  if (attr_lines.any? || enum_literals_for(classifier).any?) &&
261
269
  grown_bottom.positive? &&
262
- grown_bottom + 8 > bounds.y + bounds.height
270
+ grown_bottom > bounds.y + bounds.height
263
271
  bounds = Ea::Model::Bounds.new(
264
272
  x: bounds.x, y: bounds.y,
265
273
  width: bounds.width,
266
- height: grown_bottom + 8 - bounds.y
274
+ height: grown_bottom + compartment_bottom_padding(size, family, size_unit) - bounds.y
267
275
  )
268
276
  end
269
277
  RenderContext.new(
@@ -293,18 +301,30 @@ module Ea
293
301
  )
294
302
  end
295
303
 
296
- def row_content_width(lines, right_pad, size, family, logical_height)
304
+ def attribute_content_x_offset(size, family, size_unit)
305
+ offset = (theme.attribute_spec.content_x_offset || Element::AttributeRenderer::DEFAULT_CONTENT_X_OFFSET) +
306
+ 2 * (size - 7)
307
+ offset -= 1 if Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit)
308
+ offset
309
+ end
310
+
311
+ def compartment_bottom_padding(size, family, size_unit)
312
+ Ea::Fonts::Metrics.compact_compartment_font?(size, family: family, unit: size_unit) ? 7 : 8
313
+ end
314
+
315
+ def row_content_width(lines, right_pad, size, family, **font_options)
297
316
  return nil if lines.to_a.empty?
298
317
 
299
- tl = lines.filter_map do |line|
318
+ lines.filter_map do |line|
300
319
  text = line.sub(/\A\S /, "")
301
- Ea::Fonts::Metrics.text_length(text, size,
302
- family: family,
303
- weight: "400", logical_height: logical_height)&.round
304
- end.max
305
- return nil unless tl
320
+ length = Ea::Fonts::Metrics.text_length(text, size,
321
+ family: family,
322
+ weight: "400", logical_height: font_options[:logical_height])&.round
323
+ next unless length
306
324
 
307
- 22 + tl + right_pad
325
+ offset = line.match?(/\A[-+~#]\s/) ? font_options.fetch(:content_x_offset, 22) : 22
326
+ offset + length + right_pad
327
+ end.max
308
328
  end
309
329
 
310
330
  def compartment_geometry(bounds, size, attr_lines, op_lines,
@@ -43,7 +43,7 @@ module Ea
43
43
  return explicit
44
44
  end
45
45
 
46
- theme_family_for(explicit) || diagram_default_family || DEFAULT_FONT_FAMILY
46
+ default_family_for(explicit)
47
47
  end
48
48
 
49
49
  def size_for(element)
@@ -72,14 +72,29 @@ module Ea
72
72
  end
73
73
 
74
74
  def style_for(element)
75
- element&.font_italic ? "italic" : "normal"
75
+ return "italic" if element&.font_italic
76
+ return "normal" if element&.style&.key?(:italic)
77
+
78
+ diagram.hand_draw ? "italic" : "normal"
79
+ end
80
+
81
+ def header_style_for(style, element)
82
+ return style unless diagram.hand_draw && style_for(element) == "italic"
83
+
84
+ style == :bold ? :bold_italic : style
76
85
  end
77
86
 
78
87
  private
79
88
 
89
+ def default_family_for(explicit)
90
+ return "Source Sans Pro" if diagram.hand_draw
91
+
92
+ theme_family_for(explicit) || diagram_default_family || DEFAULT_FONT_FAMILY
93
+ end
94
+
80
95
  def theme_family_for(explicit)
81
96
  return unless theme.themed?
82
- return theme.font_family if DEFAULT_MARKER_FAMILIES.include?(explicit)
97
+ return theme.font_family || "Carlito" if DEFAULT_MARKER_FAMILIES.include?(explicit)
83
98
 
84
99
  theme.element_font_family || theme.font_family
85
100
  end
@@ -79,6 +79,7 @@ module Ea
79
79
  text_pos, mult_pos = tree_positions(end_kind, anchor, role,
80
80
  text_pos, mult_pos)
81
81
  mult_pos = tree_left_multiplicity(end_kind, heading, anchor, mult_box, mult) || mult_pos
82
+ text_pos = tree_left_property_role(connector, end_kind, heading, anchor, text_box, role) || text_pos
82
83
  elsif mult_box && stored_offsets_zero?(mult_box)
83
84
  # Start at the legacy default; measured directional seats
84
85
  # below replace it for default Cambria labels.
@@ -210,8 +211,9 @@ module Ea
210
211
 
211
212
  def source_property_seat?(connector, direction, box)
212
213
  phantom_property_box?(connector, direction, box) ||
213
- (%i[up left right down].include?(direction) && property_role_box?(box, direction) &&
214
- source_property_font?(connector, direction))
214
+ (%i[up left right down].include?(direction) &&
215
+ property_role_box?(box, direction, connector.style.to_h) &&
216
+ source_property_font?(connector, direction, box))
215
217
  end
216
218
 
217
219
  # Phantom outlines carry synthesized zero boxes without alignment.
@@ -392,9 +394,23 @@ module Ea
392
394
  [nil, "V"].include?(style[:tree])
393
395
  end
394
396
 
395
- def property_role_box?(box, direction)
396
- alignments = direction == :down ? [1] : [0, 1]
397
- box && alignments.include?(box["alignment"]) && default_presentation?(box)
397
+ def property_role_box?(box, direction, style = {})
398
+ return false unless box && default_presentation?(box)
399
+ return [0, 1].include?(box["alignment"]) unless direction == :down
400
+
401
+ case box["alignment"]
402
+ when 1 then true
403
+ when 0 then persisted_plain_down_box?(box, style)
404
+ else false
405
+ end
406
+ end
407
+
408
+ # Persisted alignment-0 boxes on untreed routes use the downward
409
+ # seat, including saved rays; the "OS" route also seats absent boxes.
410
+ def persisted_plain_down_box?(box, style)
411
+ return true if orthogonal_stub_route?(style)
412
+
413
+ !stored_offsets_zero?(box) && style[:tree].nil?
398
414
  end
399
415
 
400
416
  def property_role_offsets(direction)
@@ -405,26 +421,58 @@ module Ea
405
421
  end
406
422
  end
407
423
 
408
- def source_property_font?(connector, direction)
424
+ def source_property_font?(connector, direction, box)
409
425
  source_property_cambria?(connector) &&
410
- source_property_route?(connector.style.to_h, direction)
426
+ source_property_route?(connector.style.to_h, direction, box)
411
427
  end
412
428
 
413
429
  def source_property_cambria?(connector)
414
430
  font_family.to_s.casecmp("Cambria").zero? && property_label?(connector, :source)
415
431
  end
416
432
 
417
- def source_property_route?(style, direction)
433
+ def source_property_route?(style, direction, box)
418
434
  case direction
419
- when :left then left_property_route?(style)
420
435
  when :right then style.values_at(:mode, :lwidth, :tree) == %w[3 2 H]
421
- when :down then downward_property_route?(style)
422
- else %w[0 2].include?(style[:lwidth]) && style[:tree] != "OS"
436
+ when :up then upward_property_route?(style)
437
+ when :left then orthogonal_stub_route?(style) || left_property_route?(style)
438
+ else downward_source_route?(style, box)
423
439
  end
424
440
  end
425
441
 
442
+ def downward_source_route?(style, box)
443
+ [orthogonal_stub_route?(style), lateral_horizontal_route?(style), mode_two_route?(style),
444
+ downward_property_route?(style), persisted_width_zero_down_box?(box, style)].any?
445
+ end
446
+
447
+ def persisted_width_zero_down_box?(box, style)
448
+ box["alignment"]&.zero? && !stored_offsets_zero?(box) && style[:stored_association_ray] != true &&
449
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
450
+ end
451
+
452
+ # Mode 3, width 2 on the horizontal lateral tree.
453
+ def lateral_horizontal_route?(style)
454
+ style.values_at(:mode, :lwidth, :tree) == %w[3 2 LH]
455
+ end
456
+
457
+ # Mode 2, width 2 without a tree seats like the other width-2 modes.
458
+ def mode_two_route?(style)
459
+ style.values_at(:mode, :lwidth, :tree) == ["2", "2", nil]
460
+ end
461
+
462
+ def upward_property_route?(style)
463
+ return orthogonal_stub_route?(style) if style[:tree] == "OS"
464
+
465
+ %w[0 2].include?(style[:lwidth])
466
+ end
467
+
468
+ # The "OS" route seats its Property roles at the same directional
469
+ # corner seats as the untreed width-2 route.
470
+ def orthogonal_stub_route?(style)
471
+ style.values_at(:mode, :lwidth, :tree) == %w[3 2 OS]
472
+ end
473
+
426
474
  def left_property_route?(style)
427
- downward_property_route?(style) ||
475
+ downward_property_route?(style) || mode_two_route?(style) ||
428
476
  (style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) &&
429
477
  [nil, "H", "LV"].include?(style[:tree]))
430
478
  end
@@ -456,6 +504,15 @@ module Ea
456
504
  [x + SEAT_X_INSET + width, y + 15] if width
457
505
  end
458
506
 
507
+ # An absent source box on a leftward tree leg seats a Property role
508
+ # at the leftward Property seat, not the rightward tree default.
509
+ def tree_left_property_role(connector, end_kind, heading, anchor, box, role)
510
+ return nil unless leg_direction(end_kind, heading) == :left && box&.[]("alignment")&.zero?
511
+ return nil unless connector.style.to_h.values_at(:mode, :lwidth) == %w[3 2]
512
+
513
+ source_property_seat(connector, :left, anchor, box, role)
514
+ end
515
+
459
516
  # A tree-route source leaving left seats its multiplicity like any
460
517
  # other leftward source rather than at the rightward tree default.
461
518
  def tree_left_multiplicity(end_kind, heading, anchor, box, mult)