Skip to content

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

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

amatulic wants to merge 12 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.

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