Skip to content

Added text.scad with write(), moved other text into it - #2046

Open
amatulic wants to merge 29 commits into
BelfrySCAD:masterfrom
amatulic:anachronist_dev
Open

amatulic wants to merge 29 commits into
BelfrySCAD:masterfrom
amatulic:anachronist_dev

Conversation

@amatulic

Copy link
Copy Markdown
Contributor

Addresses #1642. Changes:

  • New file text.scad, containing modules write() and write3d, and a couple of useful functions to get font sizes
  • Moved text() and text3d() into text.scad
  • Added text.scad to std.scad

Still TBD: write_path()

@amatulic

Copy link
Copy Markdown
Contributor Author

@revarbat - BelfrySCAD generates the doc for text.scad without any problem, but I cannot figure out why it keeps failing here. The message in the error report doesn't clarify the situation.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

Looking at the docs for write() I think that the letterspacing names should perhaps be letter_spacing, letter_space_em and letter_space_ref and they should all work the same way, namely that 0 adds no space, positive adds the designated amount of space and negative removes space. Having 1 be the "adds no space" option seems more confusing and also inconsistent since they don't all work that way. In fact, I feel a little uncertain that I understand exactly what they do...like is it multiplying the space...I think it's confusing. It basically implies that the operation is the incorrect thing that OpenSCAD currently does. It should be laid out as "There are three ways to letter space, either in CAD units, in units of em, or in units of "0" width (or $refchar)" and then they should all be clearly additive, with 0 meaning add no space.

Is it right that we have max_width and max_height as the 2nd and 3rd positional parameters instead of box? I was imagining it the other way. The way things are now I wonder if box= is unnecessary.

The size section of the docs:

//   size = OpenSCAD font size, same as the size used in `text()`. If omitted while both `max_width` and `max_height` are not set, a warning is displayed in the console and `size=10` is assumed.
//   cap_height = Height of a capital letter, using `$refchar_cap` as the reference character.
//   nom_height = Height of normal characters, from nominal ascender to nominal descender.
//   full_height = Maximum height possible in the font, from maximum ascender to maximum descender.
//   iline_height = Interline height for the specified font; the resulting glyph size may be much smaller.
//   em = Standard font unit size, the size of the em-box in which the font was designed.

I assume that you don't get a warning if you omit size but give em, say. I think you should delete the thing about what happens if it's omitted. Also include in the description of size the .72 relation to the em.

Another concern is that you don't clearly indicate that these various options SET the font size. So they need to say something like "Set font size by specifying the height of the capital letter "H" in the font (or $refchar_cap if it is set) "

I notice you use "full_height" here but in vfit you use "max" instead of "full".

Why would you turn wrap_optimize off?

I fed the API to claude and asked for feedback. It didn't say much of use. It did note that indent says it's sometimes ignored (when centering is on or no font selected) and it hopes that you get at least a warning message in this case. I hate it when parameters are silently ignored as I have many times spent tens of minutes trying to debug why a parameter mysteriously has no effect and it turns out it's because some other parameter I didn't notice disabled the parameter I was trying to set. I think that ideally parameters should never be ignored. Claude didn't complain, but I see a bunch of other things that are documented as "ignored if...." which I think should all be asserts. If you ask for something dumb/impossible it should not be quietly ignored. That leads to user frustration and confusion because, as noted, you don't understand WTF is going on and why you change this parameter and nothing seems to happen.

The other thing claude complained about was hfit and its interaction with justify. And I agree that the docs as written are puzzling. I don't understand what/how hfit works with justify.

hfit = Determines the the bounding box is calculated for horizontal alignment for anchoring, as well as for `align="justify"`. When set to "width", the `max_width` parameter is used. When set to "tight", the rendered horizontal width of the text is used (longest line for multi-line text). Default: "tight"
//   vfit = Determines how the bounding box is calculated for vertical alignment, accounting for `line_spacing` and `para_spacing`. When set to "max", the bounds fit the maximum ascender and descender for the entire font set. When set to "nominal", the nominal ascender and descender is used. When set to "tight", the bounds fit the actual ascender of the top line and actual descender of the bottom line. Default: "nominal"

I think both of these are kind of confusing as written. The pervasive passive voice doesn't help with clarity here.

So maybe something like:

hfit = Determines the with box used for anchoring the text: "tight" sets the width to the rendered horizontal width of longest line in the text, "width" sets the width to the max_width value. Default: "tight"

What happens if max_width is INF? relation to justification is mysterious so I didn't propose anything there.

vfit = Determines the height of the box used for anchoring the text: "tight" sets the height to the actual vertical size of the rendered text, "nominal" sets the box height using the nominal ascender and descender height for the font, and "max" sets the box height usingthe maximum ascender and descender height for the entire font.

From reading this it's not clear what "nominal" actually does. The original text said "font set". Is that the max over all fonts (that's what a "set of fonts" seems to be)---but that makes no sense so presumably not. A more detailed discussion of this stuff should probably appear in the description.

The text on bounding boxes says "...the wordwrapped text invariably doesn't span the entire dimensions..." If you give 2 dimensions the text won't be tight regardless of word wrapping. I would actually use the word "unlikely" instead of "invariably". I would define the tight bounding box more directly, not just tell the reader to think about it. e.g.

"The tight bounding box is the box that exactly contains the rendered text. The user-defined bounds are specified using the max_width and max_height parameters, or the box= parameter, and one or both of them may be omitted (infinite). The anchoring box is the box used for anchoring the text. You control its size using the vfit= and hfit= parameters. By default the width of the anchoring box is ....

As I ponder the above I wonder if making boxes the central thing is the wrong way to explain it. In particular, it seems awkward when it comes to the vertical box extent. And actually does vfit affect how the text fits into the user bounds? Hmm...yes it does. So changing vfit actually changes the text size, not just the box size. It doesn't seem like hfit can have the same effect---it really is just about boxes. (But maybe something to do with justification complicates matters?)

The horizontal extent of a block of text is the actual width of the longest line of the rendered text, typically including a small margin defined in the font itself around the glpyh. You can choose between three different ways for defining the vertical extent of a text block. In can be "tight", the actual height of the rendered text. It can be "nominal", where the height of the text takes into account the typical space required for ascenders and descenders in the font, regardless of whether they appear in the specific text. This gives a more uniform and predictable height that doesn't vary from one text block to another. Finally the vertical text extent can be "maximal", where the height is based on the maximum possible vertical space needed for any glyph in the entire font. This may leave a large amount of extra space around most font glyphs depending on the font design.

In the examples I checked the difference between nominal and max seemed very small. Is it sometimes big?

I originally wrote the above with vfit options called out but then I realized that things are more of a mess because you use this in vfit but you also use it to specify font size, e.g. nom_height= and full_height=, so the concepts need to be defined clearly up front, not only for vfit. Also you need to standardize on "maximal" or "full" for how you want to describe the largest possible vertical size extent.

Once you have the above in place it now makes sense to talk about defining the size with reference to the noimal and maximal (or full) text extent.

But we now have a problem that hfit and vfit are doing different things even though they have parallel names. That seems like it makes it hard to write a clear doc text. Actually hfit has bugs and also unexpected behavior.

If I do write("text", max_height=15)align([LEFT,RIGHT])square(10) the text appears centered with a box at the left and right. If I add max_width=100 the text appears left aligned. That's unexpected. Why does a huge width do something different than an infinite width by default? It seems like the default text layout should be centered when it's just one line of text.

The bug is that in this case, the anchoring is wrong. It appears that it ignores hfit, actually, so when hfit="width" I'm supposed to anchor on the user given width but that doesn't happen, it anchors on the text itself. And when max_width is given it anchors on fictional centered text that doesn't exist.

Getting back to documenting what happens....I had to do testing to understand what hfit did with "justify" because I couldn't guess it from the docs. It seems like you've overloaded hfit with two unrelated functions. What if I want to justify a text block and have if tight but anchor on the user box? That seems to be impossible.

I think you should have a separate boolean, justify_tight=true/false that separately controls this. Docs for justify_last should say: allowed options are "left", "right", "center". And it should be an error if you give something else. (Currently it just does left if you make a typo.)

And actually, maybe we have a similar double-use problem with vfit. Or maybe not...I'm uncertain. You're using vfit to determine the anchoring box but also the way we measure text height, which determines what text fits in a space. What if I want to fit the text into the box using "nominal" but then anchor on the text itself.

You said on the chat that you already propagate some $ variables, but I don't see anything documented under Side Effects. Any such variables that are intended for user use and not just internal use, should be documented as side effects. I think we should nail down the stuff above before deciding on the right $ variables. The proposal I tossed off was to create an object called $write that had various different box dimensions as its fields, and maybe other data as well if there's something else we wanted to pass forward (number of lines of text?).

@adrianVmariano

Copy link
Copy Markdown
Collaborator

Maybe vfit should remain as it is and hfit goes away and we add vanchor and hanchor or something to specify anchoring? Anchoring could also be done as a pair, like anch_box="tight" gives you the tight box, and anch_box="user" gives you the user specified box. And anch_box=["tight","nominal"] specifies the vertical and horizontal in a mixed fashion. Not sure if that is better than a pair of params.

@amatulic

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed review!

  • Changed letterspacing names as you suggested.
  • Changed relative letterspacing to be a fraction of the em or ref character, rather than a proportion. Now 0 means no change, positive and negative values increase or decrease spacing.
  • I made no change to the box argument. I found that writing box=[300,200] is a convenient alternative to writing max_width=300, max_height=200. It can be stuck at the end of the argument list like we do when there are synonyms to arguments. Let me know how you want to handle this.
  • "I assume that you don't get a warning if you omit size but give em, say." If there are no sizes specified, and no width and no height, then size=10 and you get a warning. This is the only case where a size is assumed. It's the first row of that table in the docs, where there is no font, no finite width, and no finite height. I removed the comment about what happens if you omit it, because this applies only if you omit all sizes, not just size.
  • Changed all checks for vfit=="max" to vfit=="full"
  • Changed font size descriptions to "Set font size ..." with explanations.
  • "Why would you turn wrap optimize off?" I have needed it both ways. It looks better to turn it off if you're outdenting a paragraph, as I had to do when adding a color description to a filament swatch, and it wrapped to a new line. It may be desirable if you need to attach something to the last word of wrapped text and have it still fit under the paragraph.
  • "[Claude] did note that indent says it's sometimes ignored (when centering is on or no font selected) and it hopes that you get at least a warning message in this case." Yes, that warning, and others, have been there since the beginning. It warns you if indent is incompatible with any justification you use (such as left-align with RTL text), or if you don't specify a font size.
  • There are only 2 instances of "ignored if" in the documentation, neither case require even a notification. Other cases get a warning about a setting being ignored. There are far too many insignificant cases that shouldn't crash the entire script with an assert. I find that running into an assert for something that doesn't matter is far, far more annoying that simply letting me look at the result. Especially if I am fiddling with parameters that have interactions, I don't want to meticulously make sure all combinations are perfect while testing. I prefer to see warnings (with a yellow warning triangle) and let it move on.
  • Changed hfit description. Your version was way better and more concise.
  • "What happens if max_width is INF? relation to justification is mysterious so I didn't propose anything there." If there is only one line of text, nothing happens. If there are multiple lines of text (the input can be a list, not just a string), then write() has to know how to position these lines horizontally in the tight box, and uses align for that.
  • "The original text said "font set". Is that the max over all fonts (that's what a "set of fonts" seems to be)---but that makes no sense so presumably not." I changed this to "over the entire character set of the font".
  • Changed "invariably" to "unlikely"
  • I have rearranged the bounding box section into three bullet points describing each of the three boxes and how they relate to each other.

Now that documentation is in place, I'll move onto the bug and the rest of your review later today.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

The reason I got to wondering about box= is that right now you would do write(text, 100, 25) to specify the width and height and if you use box this becomes write(text, box=[100,25]) which is more trouble. I was expecting box to be the first positional parameter and max_width and max_height to be the named arguments. But the way you have it may actually be better. It just raises questions then about the utility of box=. The time it would be useful is when it's coming from somewhere else as a 2-vector already. The argument against the current design is that it's not parallel with other things, like if I'm putting my text on a rectangle I have rect([x,y]) but write(text, x,y). One could argue that being able to do just write(text, text_width) is nice, though I don't know if that's the more useful case when just giving one dimension? Hard to say.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

Another observation, if I give max_height but not max_width I only get one line, even when max_height is generous and should allow word wrapping. Shouldn't this case wrap to use the allowed vertical space so it fits the text in the minimal width?

@adrianVmariano

Copy link
Copy Markdown
Collaborator

Similarly if max_width and max_height are given but no font size I always get one line, but shouldn't it pick the largest font size that fits the text with wrapping into the box?

@amatulic

Copy link
Copy Markdown
Contributor Author

Similarly if max_width and max_height are given but no font size I always get one line, but shouldn't it pick the largest font size that fits the text with wrapping into the box?

No, that's an indeterminate problem. There could be multiple solutions of wrapped lines and font size.

If you give a complete box with no font size, the font size is adjusted to fit within whichever limits are hit first. If you want wrapping in such a case, it's best you do it yourself by inserting \n in the desired places in your text.

An oversight on my part is omitting the feature where if you specify only max_height with a font size, then it should try to wrap the text as much as needed to fit into that height. At the extreme end you get one word per line. I have not written that yet. I made a couple of attempts this past week but it's involving some refactoring.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

The problem of given a box and no font size, find the largest font size that fits with wrapping can't possibly be indeterminate. Either you can or you can't do it with font size X. There must be a largest size because otherwise you're claiming I can make the font arbitrarily large and it still fits.

Now it's a difficult problem that probably requires repeatedly trying to fit the font into the box at different sizes and iteratively locates the largest one that fits.

@amatulic

amatulic commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor Author

I see. You're proposing an iteration, in which the font size converges to a value where the wrapped lines don't exceed the bounds in either dimension. It may not be too hard, and may be reasonably fast if an epsilon of 0.1 is allowed.

What write() does now is find an exact solution for the font size that would fit the given text constrained by the bounds, and the text can be multi-line text that you have composed using newlines.

The question is, what's more useful? Two situations could both be supported by write():

  • If I have already decided where I want the newlines to go, I wouldn't want the font size to be so large that it induces more wrapping that I didn't want. In this case, for multi-line text, the font would be sized so my text as given fits in the bounds.
  • On the other hand, a single line of text with no newlines could be sized and wrapped iteratively as you propose.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

I think the non-wrap version (already implemented?) is probably more useful to users.

@adrianVmariano

adrianVmariano commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Ok, in looking over updated docs....

First paragraph something like:

The write() module is the BOSL2 replacement for text(), compatible only with development snapshot versions of OpenSCAD 2025.07.11 or later. It creates 2D single line or multi-line text with full anchoring support. You can specify vertical and/or horizontal limits and automatically scale the text to fit in your limits. For multi-line text word wrapping is available to fit your text into limits. The write() module supports several ways to specify font size, and it can perform letterspacing correctly. You can also change font style (e.g. bold or italics) in the middle of text.

Docs need things like "would be" and "may be" changed to "will" or "is" in several places.

The letterspacing parameters are inconsistently named. They need to all have the same prefix, e.g. letter_spacing, letter_spacing_em and letter_spacing_ref. Or if you want letter_space_em then letterspacing -> letter_space. I think when i asked AI it liked "letter_spacing" better than "letter_space" but I have no particular preference here---but use the same one in each of the 3 places.

I would simplify the descriptions of max_width and max_height to simply say:

// max_width = Constrains text to fit within a maximum horizontal width. As a positional argument, this can be set to a [width,height] vector instead of specifying max_width and max_height separately. Default: INF
// max_height = Maximum vertical height the text can occupy. Omit this if you already specified [width,height] together in the prior positional parameter. Default: INF

The other stuff is clutter with respect to what these things do, namely, set the bounds.

I think vfit should be renamed to vbound. I think I'm now happy with the API with regards to the things we have worked on so far, though I think the docs can be improved. So there's a section labeled "bounding boxes" that talks about limits and bounding boxes and anchor boxes and that seems confusing. Especially since limits don't get used later and there's no limits parameter. So here's my proposal: we have one "bounding box". Nothing else is a "bounding box". The status of the anchor box is a little unclear now because there doesn't seem to be code to control it, but I think the bounding box section can go something like this. Some of this I already wrote above and it didn't make it into the docs, maybe because of the wall-of-text effect. I'm going to mark as block quotes things I think you should add to the docs.

The bounding box is the box that defines the horizontal and vertical size of the text. It determines whether the text can fit into a space, or what it means to align the text to the top or bottom of a space. The width of the bounding box is the actual width of the longest line of the rendered text, typically including a small margin defined in the font itself around the glpyhs.

You can choose between three different ways for defining the height of the bounding box. It can be "tight", the actual height of the rendered text. It can be "nominal", where the height of the text takes into account the typical space required for ascenders and descenders in the font, regardless of whether they appear in the specific text. This gives a more uniform and predictable height that doesn't vary from one text block to another. Finally the vertical text extent can be "full", where the height is based on the maximum possible vertical space needed for any glyph in the entire font. This may leave a large amount of extra space around most font glyphs depending on the font design.

I'm not sure it makes sense to use "limits" when the parameters are max_width and max_height. I'm also not sure how to write about the limits. The X limit? The horizontal limit? The width limit? In any case, this concept needs to appear in the docs before it is used in the auto-scaling section. I also note that you have a section heading on every paragraph, which seems a little unnecessary and maybe confusing, actually. The section labeled "input text" really seems like it covers the next two sections as well (like they are subsections).

write("acbum blah fjdksa jfdsa ewrewr fdsja werq safdj asdfj jrewqreqw fdjs fj ad rej jfdasj e fjj jwerj",INF,50,indent=40,size=10)
   align([RIGHT,LEFT]) square(10);

Anchoring bug above: The square on the right is not at the right side of the text, but is at the right side of the middle line.

image
write("acbum blah fjdksa jfdsa ewrewr\n fdsja werq safdj asdfj\n jrewqreqw fdjs fj\n ad rej jfdasj e fjj jwerj",50,indent=40,size=10)
   align([RIGHT,LEFT]) square(10);

Above generates a warning message and then an OpenSCAD warning/error message. Seems like you're going a little overboard trying to make things work even when input is bogus.

I gave "font=5" by accident and got a cryptic warning. Check that font is a string?

write("acbum",max_width=50,max_height=60,size=5)
   align([RIGHT,LEFT]) square(10);

I think text position is wrong? It looks like maybe default box_align is LEFT but later processing thinks it's CENTER.

image

I found the descriptions of the text input overly complex and focused on implementation internals. One major point that I know confused me was that if I want just a series of lines those are "paragraphs". The advice to use "\n \n" for a blank line does not work. I would write the input description more like this as the first thing after my new proposed paragraph above.

The text input may be a string or list of strings, which may include embedded newline characters ('\n') or codes for nonbreaking spaces or inline font styles (described below). New paragraphs start at the beginning of each string in the list of strings and at any embedded newline character in any of the input strings. The space between paragraphs is is controlled by para_spacing which is a multiple of line_spacing and defaults to one, so if you want a sequence of lines with specified line breaks, give your lines as a sequence of paragraphs. The best way to create more space between paragraphs is to adjust para_spacing. If you must create a blank line in your text, use a space between consecutive newlines: "\n \n".

(That last bit will only work if collapse_spaces is false, but it defaults to true; if that default remains, it needs to say that instead.)

I think that's maybe enough...then explain the limits

The max_width and max_height parameters specify optional limits on the size of the text. If you give either one without a font size then the text is automatically scaled to fit within your limits without word wrapping. If you combine a font size with limits then the text is word-wrapped to fit into the dimensions. If the text is too large to fit it will be created anyway and an informational warning message appears on the console.

Right after this give the explanation of bounding box from above.

Is there some reason to have collapse_spaces default to true? Seems like this is the less expected / more confusing behavior. The above doesn't work by default because of collapse_spaces.

I think it's unnecessarily pedantic to say all over the place that outdenting is actually indenting of everything else.

So in the arg list just:

// indent = First line of paragraphs are indented by this amount. If negative, first lines are outdented. No effect if font size is not set, or if align="center". Default: 0

The justify_last command doesn't sanity check its input. I tried "justify" and nothing happened.

I think a paragraph about layout should look something like this. Maybe it goes after the bounding box discussion:

The align= parameter controls text alignment. Set it to "center" for centered lines, "left" for left aligned text, "right" for right aligned text and "justify" to justify the text. If the text is not aligned to "center" you can add paragraph indentation using indent=, which indents the first line of each paragraph, or if it is negative, outdents the first line of each paragraph. The indent= parameter requires that you specify a font size. When justification is enabled you can control the behavior of the last line using justify_last, which can be set to "left", "right", or "center". The justify_tight= parameter determines the width of justification. When it is true the justified text block has its minimal size, defined by the width of the bounding box. If you set it to false then the text will be justified to the specified horizontal limit. This has no effect if you didn't give a horizontal limit.

The box_align parameter controls how the bounding box of your text is positioned within the limits. It is given as a direction (e.g. RIGHT, BACK, TOP+RIGHT). Components of the direction that correspond to unlimited text size are ignored. So if you do not give max_height then box_align=RIGHT+BACK will align to the RIGHT and ignore the BACK component.

I would rather have it be an error to align BACK if you have not given a height limit. I think it's confusing to align BACK and have it align CENTER.

I don't love the name box_align. align_to_limits? Not sure I have a better candidate.

Ok. Enough for now. I'm going to try to make an issue list:

  • Various proposed doc improvements (block quoted text above)
  • Correct naming of letter space options
  • Change vfit to vbound
  • Error if box_align has components in unlimited directions
  • Change default for collapse_spaces to false?
  • Add $ variables to propagate size of text (at least tight size and bounding box size)
  • Add more options for controlling anchor box size

The last two may require more discussion, but I wanted to keep them on the list.

@amatulic

amatulic commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author
  • Various proposed doc improvements (block quoted text above) -- done
  • Correct naming of letter space options -- done
  • Change vfit to vbound -- done
  • Error if box_align has components in unlimited directions -- not done
    • Unnecessary. There are no "unlimited directions" at the time of the render; the boxes have been figured out and are finite, so there is no error.
  • Change default for collapse_spaces to false? -- not done
    • Mandatory for input text that spans multiple lines (see the examples) to strip out leading spaces, and in most cases where one would use text(), it's irrelevant and unlikely to come up, particularly when working with proportional fonts.
    • It's also not surprising to strip spaces, given that HTML has existed for over 30 years and consecutive white space is always collapsed except in certain tagged blocks (preformatted or code).
    • write() collapses only consecutive space characters (not all white space)
    • The example about adding a blank line always works with a nonbreaking space: \n{ }\n.
  • Add $ variables to propagate size of text (at least tight size and bounding box size) -- TBD
  • Add more options for controlling anchor box size -- TBD

The first example you identified as a bug was actually working but the box registration was off, causing incorrect positioning of the text and the attachments. I fixed that, but I cannot figure out what's wrong with this short example you posed (I added show_bounds=true):

write("acbum",max_width=50,max_height=60,size=5,show_bounds=true)
    align([RIGHT,LEFT]) square(10);

I think there's still something fundamental that I am not understanding about attachments. Stripped of the box display stuff, here's what I have:

    translate(parent_offset - w.boundbox_offset)
        attachable(anch, spin, two_d=true, size=w.boundboxsize, anchors=_line_anchors(w.baseline_pos, w.boundboxsize)) {
            union() {
              // ... display the text ...
            }
            children();
        }
  • parent_offset is normally [0,0,0]. It can change if write() calls are stacked as parent-child.
  • w.boundbox_offset is where the tight bounds are moved within the user limits. It is necessary to translate this offset so the bounding box is centered on the origin (which may cause the user bounds to shift). It is also necessary for anchor=LEFT or anchor=RIGHT to work.
  • w.boundboxsize is the dimensions of the bounding box.

What am I missing?

Latest commit with the changes above have been pushed.

@adrianVmariano

adrianVmariano commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

The variable err9 is assigned twice. Note that you can save on dummy variables by chaining assertions like check=assert(cond1)assert(cond2)assert(cond3)assert(cond4); so four assertions but only one dummy variable.

I think to fix anchoring you need the argument cp=w.boundbox_offset given to attachable() and you need to remove that factor from the translate() that is above attachable. You're trying to lie to attachable about where the thing is and clean up later with a translate on the outside.

BTW,

  • is a checkable box

I think you missed the point about error with regards to box_align. The point is that if a user asks for box_align=BACK then something should happen. It should never be a synonym for box_align=CENTER. Or if you insist that bogus stuff work, there should be a warning message. So there needs to be either an assert or a warning if: max_height=INF and box_align.y !=0 or box_align.z !=0. And same thing if max_width=INF and box_align.x !=0. This is one of those "and we ignore what you said" cases and it needs to be either a warning or error.

Some suggested doc changes:

//   #### Input text
//   The `text` input must be a string or list of strings, which may include embedded newline
//   characters (`\n`) or codes for nonbreaking spaces or inline font styles (described below). A
//   new "paragraph" starts at the beginning of each string in the list of strings, **and** at any
//   embedded newline character in any of the input strings.  Consecutive spaces in your input
//   collapse to a single space.  
//   .
//   The space between paragraphs is controlled by `para_spacing`, which is a multiple of the font's
//   interline height and defaults to one. If you want a sequence of lines with specified line
//   breaks, give your lines as a sequence of paragraphs. The best way to create more space between
//   paragraphs is to adjust `para_spacing`. Consecutive newline charactesr only give one paragraph
//   break.  If you must create a blank line in your text, use a
//   nonbreaking space between consecutive newlines, for example: `"Line1\n{ }\nLine2"`.
//   .

Delete the sentence after that about indent because indent is described later.

The section about inline font styles add an intro sentence:

You can create an entire text in a other font styles by specifying the style with the font, e.g. font="Times:Italic". Inline font styles enable you to switch between styles on the fly using inline style codes.

Then after the list of styles change first sentence:

...produces text where "one" is set in the style specified in your font= parameter (or the regular style if no style is specified), "two" is set in the italic style, and "three" is set in the regular style. When write() counters one of these inline codes, it renders subsequent characters in the corresponding style until...

Delete the last sentence that says "If you want a new paragraph to have a different style...". That's obvious.

Then the line about nonbreaking space should be changed to

The nonbreaking space, "\u00a0" is not collapsed into adjacent spaces and it prevents word wrapping. You can include the unicode character directly or you can specify it using {{ }} or the shortcut { }.

Delete the heading labeled "bounding boxes" or give it a different name. There is only one "bounding box" so that's confusing. I guess if you want every single paragraph to have a heading you could rename it "The Bounding Box" as the heading.

I found the section describing the bounding box to be unnecessarily wordy. I suggest

//   #### The Bounding Box
//   The **bounding box** is the smallest rectangle that completely encloses the text. It determines
//   whether the text can fit into a space, or what it means to align the text to the top or bottom
//   of a space.
//   .
//   The width of the bounding box is the actual width of the longest line of the rendered text,
//   typically including a small margin defined in the font itself around the glpyhs.
//   .
//   You can choose between three different ways for defining the vertical bounds of the bounding box by setting the `vbound=` parameter:
//   * "tight" - the vertical bounds correspond to the actual vertical space occupied by the rendered text. For example, text consisting of only uppercase characters has a vertical bounds that do not include lowercase descenders.
//   * "nominal" - the vertical bounds account for the typical space required for ascenders and descenders in the font, regardless of whether they appear in the specific text. This gives a more uniform and predictable height that doesn't vary from one text block to another.
//   * "full" - the vertical bounds are based on the maximum possible vertical space needed for any glyph in the entire font. This may leave a large amount of extra space around most font glyphs depending on the font design.
//   .

There's some stuff after this about the bounding box having margins and alignment. That should be deleted because alignment is described in the layout section.

//   language = Language hint for text shaping, passed through to `text()`. May affect language-specific glyphs and font features. Default: "en"
//   script = Writing-script hint for text shaping, passed through to `text()`. May affect glyph selection and shaping. Default: "latin"

Do you need to comment on ligatures in the letterspacing paragraph? The last bit of that should say:

By default, $refchar_width="0" (zero).

I passed the API to ChatGPT and asked for critique. Here are its main points to either consider or ignore:

  • Biggest issue: implicit ft vs wrap. Clever but weakest part of the API. "Was size supplied" has become a mode switch. Seriously consider an explicit mode parameter, layout="fit", layout="wrap". You could maintain the current interface when the parameter is omitted.
  • It doesn't like allowing max_width to be a pair like we decided to do
  • It thinks the align variables are confusing. (Note, I don't love "box align"). It thinks text_align and block_align are a good pair of options here.
  • Right now indent is described in two different ways, as indenting everything else or as outdenting when negative. The suggestion is to describe it as setting the horizontal offset. "Horizontal offset of the first line of each paragraph relative to subsequent lines, with positive numbers in the direction of text flow. Negative values produce a hanging indent." I think it does make sense to be consistent, so either this language in both places, or the indent/outdent language in both places. I think this description is very elegant, but possibly a little harder to follow?
  • It wants BASELINE and TEXTLINE to require named arguments explicitly for tight and rtl, at least
  • It questions the default font, specifically the use of Bold style. Does the default font match the OpenSCAD default?
  • Chaining text e.g. for size switching is not convenient to do programmatically (e.g. if the text and number of strings isn't known until run time)
  • How do you render {{ literally?
  • Paragraph/newline semantics seem unnecessarily surprising. I agree it's surprising: I was surprised. But I don't know if it's unnecessary. It wants \n\n for a paragraph and \n for newline.
  • iline is too cryptic. It suggests interline_height. That seems valid, unless you want to just go with line_height, which seems OK to me. The "i" is what makes it cryptic. Actually, should iline_size set the font based on the interline size or based on the actual font spacing? I guess this is "line advance" which is the product of line_spacing and interline? You previously told me that interline is the same as the full text height so instead of line spacing this could be "full_height". I notice that chatgpt thinks full glpyh size and interline are not interchangeable and collapsing them would be an API mistake that wouldn't work for all fonts.

I also asked chatgpt about errors and warnings and it supports your position more than mine.

  • Impossible/invalid value = Error
  • Two parameters contradict each other | = Error
  • Parameter strongly suggests caller misunderstood the API = Error, or at least warning
  • Parameter is meaningful generally but irrelevant in this particular configuration = Ignore silently if the result is unambiguous
  • Parameter is partially applicable = Apply the meaningful portion and ignore the rest
  • Layout cannot satisfy a requested constraint = Usually warning + best-effort output

It made the interesting point that generic code is easier to write if options that aren't relevant are ignored.

I also asked for advice about propagating $ variables. It does seem like propagating a $write object is the best strategy. I assume that works. I know we want bounds information, but AI suggested some things I hadn't thought of:

  • baselines - y coordinates of all baselines
  • line bounds - per line horizontal/vertical bounds
  • size - resolved font size actually used
  • line count
  • line advance
  • max_width and max_height
  • bounding box - actually selected
  • tight_bounds
  • nominal bounds
  • full bounds

For defining the anchor box it suggests a pair of strings, a horizontal string and vertical string, with the horizontal options being "bounds" or "max". The vertical options:

  • bounds
  • tight
  • nominal
  • full
  • line (some choice that effectively gives the line advance)
  • max

@amatulic

amatulic commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

The checkboxes you put in your comments are not clickable for me.

Putting cp in attachable() seems to have fixed the problem. The three buggy examples from yesterday now work. I did this for write3d() too, but had to fix the BASELINE and TEXTLINE anchors for the TOP and BOTTOM positioning to be correct.

I think you missed the point about error with regards to box_align. The point is that if a user asks for box_align=BACK then something should happen. It should never be a synonym for box_align=CENTER

Something does happen. It's aligned to the back, just as you asked. The alignment isn't being changed silently to CENTER, it's kept as BACK. The documentation saying it's "ignored" is wrong, and I have changed it. If you don't set either of the limits, they take on the size of the bounding box. If the inside box happens to be the same size as the outside box (which is the case when you don't set max_height or max_width), it isn't an error to specify box_align=BACK. The alignment is still done but the effect is zero. I tried to clarify it in the docs. Maybe it doesn't even need to be said.

I changed the docs per your other suggestions, and added some words about ligatures. Ligatures don't appear because each character of the input text is uniquely rendered according to its position and font style. If you want a ligature you need to put the unicode for it in your text, but it may look weird for anything other than the default letterspacing.

write() and write3d() now propagate $write_obj, which contains everything about the text to be rendered. Documenting that for public consumption isn't going to be easy. I always meant that to be for internal use. That documentation is currently in about 100 lines of private comments near the top of the file.

The AI has interesting suggestions, several of which I implemented.

  • A mode parameter, layout="fit", layout="wrap" leads to more contradictions and more opportunities for user error. A "wrap" layout spec is meaningless with no finite max_wdith, and a "fit" spec is meaningless with no font size. At best it's redundant and at worst it's confusing.
  • I changed align to text_align, and box_align to block_align.
  • Changed description of indent parameter in the argument list and tweaked the introductory text.
  • I could not change the documentation of BASELINE and TEXTLINE to have the args n,[pos],[tight=],[rtl=] because the presence of the first = is interpreted by docsgen as the delimiter between the argument name and its description. I don't know how else to suggest that tight and rtl be named parameters. HTML entity codes don't work there.
  • The default font deliberately doesn't match the OpenSCAD default. We aren't trying to duplicate text() behavior. Because most uses of OpenSCAD are for 3D printing, a boldface font actually does print better. I have never had good results with the regular style Liberation Sans. That's why bold is the default. I have no objection to changing it (it's a trivial change), but the reason needs to be better than "it's what text() uses".
  • "Chaining text e.g. for size switching is not convenient to do programmatically". There is a demo of doing this. Why is it not convenient to chain them with parametric strings? You get arbitrary strings and arbitrary sizes and string them all together in a line. What's hard about that? I don't understand this comment. Chaining is a feature that the user can use, or ignore.
  • "How do you render {{ literally?" You just do. As long as the double left brace doesn't contain one of those style codes before a closing double right brace, it isn't interpreted as anything special. The text is searched for the entire code string, not just a starting double brace.
  • It wants \n\n for a paragraph -- Is that worth doing? I would have to add to the preprocessing a search for \n\n or \n \n with arbitrary amount of white space between, and replace them with \n{ }\n internally.
  • Changed iline_height to line_height. It is a multiple of the font's interline height, and the documentation is clear about that. Interline height is usually but not always the same as the full text height. That's why it's a separate property of the font. Full text height and interline height aren't interchangeable.

I just pushed a new commit with these changes.

If you installed BelfrySCAD, it has a really nice feature "View > Show Docs" in the pulldown menu. You can see approximately what the docs would look like, including all the rendered examples. Of the examples I made up, there may need to be more, or they may need re-ordering. Be sure to get the latest release -- I submit bug reports every day (it's getting harder to find things to report) and Revar has been good about turning them around the same day or the next day and making a new release.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

This is just a response to what you wrote above, not a full review of the latest.

I actually do NOT think it makes sense to just propagate your internal structure with 100 things in it. I think we should propagate something more limited and thought out that just has a few key pieces of info we think users may want. The simple fact that you say it's going to be terrifying to document this means it's going to be terrifying for users to read the docs. I'd rather have a propagation that misses things and we can add a few more later than something overwhelming. Also, the name $write_obj isn't good because "obj" doesn't add anything here other than meaningless excess characters to type. (Would you make a variable called $text_height_number instead of $text_height?)

Weird that you can't click the text boxes. I have been able to click textboxes in issues others have posted. I wonder if it matters if it's in the issue vs in a comment.

I agree that we don't need to follow the same font as text() does but it does seem potentially confusing not to do so, and there need to be instructions on how to get the default font if you want it. I think we should poll others to see what people think is the right choice for default font.

I don't think you understand the chaining issue. Suppose I have a list of 2N words where N is not known until run time and I want to set the words in alternating size, so word 0 is at size 15 and word 1 is at size 10 and word 2 at size 15 and so on. How do you do that? You can't use chaining because chaining requires hard coding the number of text blocks and here that's not known until run time. Now....is this an important issue? It doesn't seem like it to me. But it is a limitation of the architecture.

I guess you can state in words that tight and rtl are named parameters and should not be used positionally.

So regarding {{r}} etc, if you really want to typeset {{r}} you're out of luck? Is that the way it works? You put a zero width space in there if you want to? Seems like a bit of documentation about this is warranted.

I thought about the \n handling and I do not think it's worth changing. But as currently documented it's confusing, because it seems like \n as things stand does double duty as a line break and a paragraph separator. The docs lean on the paragraph interpretation, but it seems like most use cases are going to be with what the user sees as several lines that are still (to the user) one paragraph. I asked AI for help. It suggests renaming "paragraph" to something else. And I like this idea.

It suggests "text block" or "input block" as its first proposal instead of paragraph, and then "rendered line" for an actual output line after wrapping or presumably just "line". The rule is "Each string in the input and each \n starts a new text block. A text block may render as one or more lines if wrapping is enabled." Then you can explain indent as "indent offsets the first rendered line of each text block relative to any wrapped continuation lines." And "para_spacing" becomes "block_spacing".

Docs could say things like this:

For example, "one\ntwo\nthree" normally produces three lines of text. If one of those blocks is too wide and wrapping is enabled, that block may occupy several rendered lines. The line_spacing parameter controls spacing between rendered lines within a block, and block_spacing controls spacing between separate blocks. These text blocks behave like paragraphs when the text is wrapped.

AI pushed back on the need for "\n{ }\n". Why can't "\n\n" be two blocks? It's pretty weird that ["acbum","","bcdef"] only produces two lines. Is there some compelling reason here?

Now I like that idea a lot, but it has a problem which is that we just made block_align. It then suggested that we rename block_align to frame_align, that we identify the space defined by the user bounds as the frame. That seems like a decent idea.

@amatulic

amatulic commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Yes, I understand the chaining issue and I still insist that the AI is imagining a problem that doesn't exist. The example you posed is easily solved using recursion. I tested this and it chains any arbitrary list of strings with alternate font sizes.

include <BOSL2/std.scad>

texts = [
    "Hello ",
    "there. ",
    "How are ",
    "you doing ",
    "on this ",
    "fine day "
];

module writealtsize(t, siz1, siz2, n=0) {
    if (n<len(t)-1)
        write(t[n], size=n%2==0 ? siz1 : siz2) writealtsize(t, siz1, siz2, n+1);
    else
        write(t[n], size=n%2==0 ? siz1 : siz2);
}

writealtsize(texts, 10, 15);
image

Regarding being out of luck if you really want to display {{r}}. It's easy to add another special code for a left brace, like {{lb}}, so you would have {{lb}}{r}} to display {{r}}. This would need to be processed after the style codes are processed.

Please suggest what would be useful to propagate in a $ variable. Bounding box size and centerpoint, and baseline start and end positions? It's unlikely that all the metadata for each character would be needed.

@amatulic

amatulic commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Latest changes:

  • Renamed block_align to frame_align.
  • Reworded things to refer to "text block" rather than "paragraph".
    • I retained the para_spacing parameter, however. I considered changing it to block_spacing but that sounded generic or ambiguous. The documentation explains that text blocks are analogous to paragraphs.
  • Eliminated need for \n \n to produce a blank line. You just need \n\n. This turned out to be simpler than I expected. For reasons unknown, years ago when I started writing this, I used str_split(s, "\n", false) to split text at newlines, and never looked at it again. But the false parameter causes empty strings to be lost. I changed it to true and made any resulting empty string into a nonbreaking space.
  • Introduced {{lb}} code for a literal left brace. There are now three literal codes, {{ }}, { }, and {{lb}}.
  • I eliminated the extra processing steps for these literal codes and folded them into the function that processes the style codes, so it's trivial to add more literal substitution codes if needed, for example:
    • {{smiley}} for ☺
    • {{times}} for ×
    • {{deg}} for °
    • {{cent}} for ¢
    • {{euro}} for €
    • or anything else that might be a convenience to the user.
  • The variable $write_data is propagated, and documented. It contains bounding box information and baseline positions.
  • Added a couple of examples, including one showing chained write() calls using recursion.

We're getting close.... probably next step is to change the default font to regular style. While I find it annoying that I must always specify boldface in OpenSCAD, I am also finding it annoying that the default in write() isn't regular style, just because it isn't a normal expectation.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

I'm now getting a duplicate assignment on err10.

The whole point of introducing the "text block" concept was to ELIMINATE reference to paragraphs outside of a word-wrapping context, not to have two terms for the same concept. The problem with "paragraph" is that it means something to users that is different than "lines". You have combined the concept of lines and paragraphs into one concept, and for most users, most of the time, the "line" interpretation is most important and should dominate, and the paragraph interpretation is not important and should be secondary. This issue is real: I was seriously confused when you presented write() the first time because I thought it was impossible to create lines.

The fix is to completely eliminate the word paragraph from any centrality in the discussion. It should only appear as a secondary term like "a text block can look like a paragraph". So introducing them as text blocks or "paragraphs" completely defeats the purpose because you have introduced two terms for one thing, one unfamiliar term (immediately forgotten) and one familiar term (paragraph), which now dominates. The term text block is new and unfamiliar so you need to use that term clearly and systematically and not suggest that text blocks are anything other than text blocks. You can say that text blocks can render as lines or as paragraphs, for example, but not that they ARE paragraphs.

If the strings are "text blocks" then "block_spacing" should be the parameter that controls the spacing between blocks, not "para_spacing". This spacing parameter does, after all, change the spacing between blocks that are functioning as lines, not just blocks that are functioning as paragraphs.

Another thing is that suppose I create a text with several short lines and I want to spread them out a bit. If I set line_spacing, nothing happens because line_spacing only changes the space between continuation lines in wrapping. So the so-called "para_spacing" parameter is actually really the line spacing parameter too! I think this means line_spacing needs to change. I came up with wrap_spacing and AI didn't give me any better ideas. So proposed doc text:

//   #### Input text
//   The `text` input must be a string or list of strings, which may include embedded newline
//   characters (`\n`) or codes for nonbreaking spaces or inline font styles (described below). A
//   new text block starts at the beginning of each string in the list of strings,
//   **and** at any embedded newline character in any of the input strings. Consecutive spaces in
//   your input collapse to a single space by default.
//   .
//   A text block may render as a single line or as several wrapped lines (a paragraph).
//   The space between text blocks is controlled by `block_spacing` 
//   which is a multiple of the font's interline height and defaults to one.
//   If you create a sequence of short lines then `block_spacing` adjusts the spacing between
//   those lines.  If you create a sequence of paragraphs then `block_spacing` adjusts
//   the space between paragraphs.  To adjust the space between wrapped lines within paragraphs,
//   use `wrap_spacing`.  You can also create vertical space by inserting an empty text
//   block either with two sequential newlines ("\n\n") or by inserting an empty string
//   into your input list.  This inserts a blank line into the output.  

Typo counters -> encounters

Change:

If you really need
//   to render a double left brace `{{`, you can do so as long as it doesn't start one of the codes
//   listed.

to

// Pairs of left braces "{{" will render literally if they are not part of a valid inline code.  

Is that bad behavior for future proofing? e.g. if you do want to add {{smiley}}?

Note that the chaining issue is super minor. I'm not sure it really warrants an example...though if you've got one I wouldn't toss it. Some of these questions are not about saying "The API is deficient and needs to be fixed" and maybe more like "The API has a limit at this weird edge case...so maybe we should document the limit?" For some reason I had in my head that the recursive solution to chaining wouldn't work somehow...not sure why.

Regarding default font and your frustration with always wanting bold, would adding a "style" parameter be helpful and worth doing for getting into bold without having to give a full fontspec? It would presumably override the style of the font.

I see you've propagated $write_data. Is there some reason not to call it $write? You apparently don't like this idea but haven't mentioned why. Instead of documenting this in a paragraph style, make one entry in the Side Effects section for each field in the object. $write.foo = whatever or $write_data.foo = whatever if it ends up being that.

I would like to see the three vbound heights propagated in $write. So something like $write.height.nominal, $write.height.full, $write.height.tight. The final missing piece I see is that there's no way to specify the vertical and horizontal extent of the anchor box. We need to work out a plan there.

write(["acbum","bcdef","de{{fgh"],max_width=50,max_height=50,size=5,para_spacing=1,frame_align=RIGHT) {
   echo($write_data);
   position(CTR)stroke(rect($write_data.anchor_box),closed=true);
   recolor("green")circle(r=2);
   recolor("lightblue")attach(CTR,CTR) square(5);
   recolor("purple")move($write_data.bounding_center) circle(r=2);
}

It appears that something is wrong with the alignment. The bounding box is taking up the whole limits even though it's supposed to be tight on the sides and nominal in the vertical direction, which means my frame_align doesn't do anything. There's no parameter to change the horizontal behavior. And vbound seems to be ignored.

It also seems that users may be surprised by the position of the green dot, so that may require an explanation. Right now the docs have no description of anchoring, which clearly needs fixing, so the problem of the green dot can go there once that's written. Basically the normal behavior is that children appear with their centers aligned with the parent center. That's not happening here, I assume because we used cp= in attachable. It seems like there are two possible ways to fix this. One is to stop using cp...but that created problems before. So then it may be that we just document that children don't appear with their centers lined up and use position(CENTER) if you need this.

On the matter of ligatures, are they eliminated even when no letterspacing is requested? Or only when letterspacing is active?

So main issues

  • Propagated output. Should it be $write instead of $write_data? Add different text heights
  • Anchor box: How do you customize it
  • Change para_spacing to block_spacing and line_spacing to wrap_spacing and weaken use of "paragraph" in docs
  • Add style= parameter to ease choosing boldface?
  • Bug with bounding box sizing?
  • Document attachment stuff (probably not possible until anchor box customization is worked out)

@amatulic

amatulic commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

All right:

  • I stopped numbering the errors and gave them descriptive names so there's no more collision between err10 instances.
  • "paragraph" changed to "text block" throughout (including in the code)
  • para_spacing changed to block_spacing
  • Documentation adjusted as you suggest, typo fixed.
  • Chaining example and recursive chaining example are already in the version you reviewed.
  • Default font changed to "Liberation Sans:style=Regular".
  • Didn't implement style=. It's redundant with the font argument, which can also include a style. You can already specify style two ways, in the font name or an inline style code.
  • $write_data changed to $write and includes the vbounds (tight, nominal, max) object.
  • Side effects broken out to document each property in $write.

In your example, add the parameter show_box=true to reveal the actual bounding box. You are just displaying a box centered on the origin, but the anchor box is not centered, only the bounding box is. The anchor box is shifted to the left. The anchor box should be centered, however, with the bounding box shifted within it.

You can see this by adding anchor=LEFT, which anchors the left edge of the bounding box to the origin, when it should be anchoring the anchor box. I fixed this by removing cp from attachable() and setting its size to anchorboxsize instead of boundboxsize. The non-working examples from a few comments ago are also working as I expect.

Try it again, and use show_bounds=true. New version has been pushed. And try out the examples in the doc.

The final missing piece I see is that there's no way to specify the vertical and horizontal extent of the anchor box. We need to work out a plan there.

How is that a missing piece? The anchor box isn't specified, it's derived. It's a computed box. It inherits its dimensions from the user specified bounds or from the bounding box if a user bound isn't given. If you don't provide a limit, the corresponding anchor box dimension is the same as the bounding box.

On the matter of ligatures, are they eliminated even when no letterspacing is requested? Or only when letterspacing is active?

The behavior with ligatures is documented in the version you reviewed; let me know if anything needs clarification. A ligature is used only if you specify the unicode for it. write() works with individual characters in the input text. It treats each character as an individual entity with its own font, style, and x,y position. There is no practical way to determine in advance what character combinations to substitute with ligatures, because this is dependent on the font being used, as well as the language.

I was thinking that it's possible for a user to define a list of custom inline codes, setting them to $write_inline, which would be appended to the internal hardcoded list.

@adrianVmariano

Copy link
Copy Markdown
Collaborator

The only reason I suggested style= was because you were expression frustration with needing bold. I agree it's redundant.

The behavior with ligatures is documented in a section entitled Letterspacing. That created uncertainty if it only applied in the context of letterspacing or more broadly. In principle you could support ligatures as long as letterspacing is off. I suppose the solution is to add a separate heading that says Ligatures. (I remain unclear on why there needs to be a heading every 1-2 paragraphs.) Without the Letterspacing heading I don't think this confusion would exist. I wonder if the very first paragraph should mention the ligature limitation. It sounds like for some users, this makes write() unusable. My brief web search suggests that this will make write() useless for indic/devanagari scripts and also arabic.

I had a brief chat with an AI about it and it thinks you should be using :features=-liga,-clig to tell harfbuzz to turn off ligatures. It also think you should determine the position of character X in text fooXbar by comparing the textmetrics output of foo to that of fooX. How are you preserving kerning? Also it says this stuff only makes sense in latin/cyrillic/greek mode. In "complex script mode" (arabic, devanagari, thai,...) the -liga doesn't do anything because that turns off optional ligatures and in these languages, the ligatures are not optional. Also in these languages letterspacing is fundamentally impossible. It sounds like if you want per character positions like are needed for putting text on a path then you need to find position incrementally, presumably with the ligatures disabled. Anyway, I'm not sure what the path is here, if it's possible to make the behavior different when letterspacing is off---e.g. treat words as units instead of letters--or if it just needs to be documented as "mainly useful only for latin type scripts." It feels sort of like having code that loops over individual letters vs looping over words wouldn't be that different structurally.

Another limitation to call out if you go letter by letter you lose OpenType Contextual Substitution.

The main problem I had with the example I posted (which I evidently didn't include an image for) was that frame_align didn't do anything. And it seemed like that was because the bounding box mysteriously grew to the size of the user limits. I can no longer create this effect, so it's apparently fixed.

There is a problem with anchors: The named anchors have z components. It turns out that the named anchor processing path doesn't collapse these into 2d. I suspect I thought that this collapse wasn't needed since it's not a user interface to specify a named anchor. One solution is I change the named anchor lookup to collapse them (or maybe the named anchor setter?) The other solution is we change write(). I suppose it probably makes sense to change the infrastructure here?

Regarding the anchor box, there are several things the user might want, so there can't be one derived anchor box. Or, at least having one box is limiting. It seems like the user might want the vertical bounds of the box to be any the vbounds, the user specified max_height, or some box size that makes vertical text chaining work with proper line spacing.

Under font sizes define cap_height directly without telling users why they need it; they can figure that out.

cap_height sets the font size by specifying height of the capital letter specified by $refchar_cap, which is H by default.

Don't say that OpenSCAD's text() has an em parameter when describing em.

You need some explanation for wrap_optimize and why you would ever turn it off.

I tried to make a box around my text like this:

move($write.bounding_center)stroke(rect([$write.bounding_box.x,$write.vbounds.tight]),closed=true,width=.1);

image

And the box is not actually tight. Well...the problem is that the center is different and I don't think I know the center. If I manually fiddle the vbound is the right length.

Some examples might benefit from a light colored background rect() that shows the relation between what the user given box and the output. I know there is show_bounds, but separately making a box that is obviously the user size is more transparent about what exactly is going on.

Might be nice to juxtapose all the sizes in one example with the "same" size.

First example that uses show_bounds doesn't explain what all the markup is about. It doesn't seem like an example that particularly benefits from the bounds being shown?

For five spaces example include a line above with five regular spaces that vanish to show the difference.

Actually show_bounds is completely undocumented. It needs a section in the description describing what-all it shows....

I normally favor separate examples, but I wonder if the hello world example with vbound=tight would be good to show together with the nominal height and same box so that the difference is very clear.

Showing non-default wrap_optimize example first seems weird. And you need some comment on why you'd ever turn wrap_optimize off....ideally an example. Why is this even an option? You could say about the example you've got that look, it's ugly, with a widow word. And then the next example answers the question of why you'd use this feature. I had to basically make my own example to find out that the very last example looks really bad with wrap_optimize enabled. So when do you disable it? When you see a big space in the middle of your text?

@amatulic

Copy link
Copy Markdown
Contributor Author

Ligatures are supported, by requiring that any desired ligature must be included in the input text as a unicode character. That is the only way, other than treating whole words as units as you suggest. (And by the way, I have never seen OpenSCAD render a ligature automatically if it could have done so, so I really don't see the relevance). I cannot think of any other way to support ligatures through the features available in OpenSCAD. We couldn't support Korean either (in fact I doubt native OpenSCAD could work with Korean) because every word is basically a ligature of phoneme characters. The AI advice is irrelevant because there is no interface into Harfbuzz other than via text().

Kerning is being preserved by comparing the advance property of every character pair against the sum of advance properties of the individual characters in the pair. To the extent that some languages have overlapping glyphs, the kerning should handle it.

As far as I know, OpenSCAD doesn't support contextual character substitution. There isn't any compelling reason to be doing something down in the weeds with font glyphs that OpenSCAD doesn't do in the first place.

Yes, the named anchors have Z components. Why shouldn't they? In 2D, the z component is always zero. In 3D, the Z component is determined by the direction vectors, TOP, BOT, CENTER. Where is the Z component a problem? I even include an example in write3d() of anchoring the baseline on a Z component.

Your example:
move($write.bounding_center)stroke(rect([$write.bounding_box.x,$write.vbounds.tight]),closed=true,width=.1);
... I had to guess at the parent write() call, but the box you drew is actually the tight box, it's just offset because you are moving it according to bounding_center, which isn't the center of the tight box because the first line has no nominal-size ascender, which the bounding box has. I didn't anticipate that someone might need to use a nominal box for rendering and a tight box for something else. If so, then I need to create a property tight_center. Should I?

@amatulic

Copy link
Copy Markdown
Contributor Author

I've added tight_center to the $write properties. It looks like it's working regardless of how I set vbound.

I also added a bunch more literal substitution codes and provided the capability for the user to add more via the $literal_subst variable.

Still mulling what we talked about in chat, how I might support ligatures by treating whole words as single units if letterspacing=0 and there is no style change mid-word.

@amatulic

amatulic commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Latest changes:

  • Support for ligatures, contextual glyph substitution, and whatever else Harfbuzz does:
    • When letterspacing=0, non-space substrings are treated as single units for the purpose of positioning while preserving ligatures and contextual glyph substitutions. This turned out to be a modest change in one function, and a tiny change in write() to render substrings instead of single characters.
    • When letterspacing ≠ 0, text is rendered as single characters.
    • If a word is interrupted by a style change, the non-space substrings on either side are treated as individual units.
  • Added, removed, and rearranged examples to make a bit more sense in the progression.
  • Did some fixing to attachments. cp is now gone from write().
  • Propagated box centers in $write.
  • Updated documentation and addressed other concerns raised in your previous comment.

After pushing this latest commit, I fixed one other long-standing bug involving words too long to fit within max_width. I'll push that after further feedback. I need one or two good demonstrations of ligatures and contextual substitution using macOS-native fonts, but I cannot come up with those examples because I haven't had a Macbook for 3 years, it's just Windows now.

I also found that cp is actually still required in write3d() to center the output of linear_extrude(). Using translation() with concatenated write3d() calls doesn't work, things get out of line vertically. That change isn't pushed yet either.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants