microsoft / maker.js

📐⚙ 2D vector line drawing and shape modeling for CNC and laser cutters.
http://maker.js.org
Apache License 2.0
1.78k stars 273 forks source link

Text union #532

Open lachlansmith opened 2 years ago

lachlansmith commented 2 years ago

Hi @danmarshall thanks for pointing me here. I understand the use case for maker js is to draw outlines and that fill happens post exportation. That's what I've done. However, due to the glyph parts the fill results in white spaces. Clearly demonstrated by an A.

Screen Shot 2022-06-25 at 12 24 18 pm Screen Shot 2022-06-25 at 12 25 19 pm

My approach to resolving this would be to union the parts of the glyph. This doesn't seem to be exposed on MakerJs.models.Text. I planned to use MakerJs.model.combineUnion on a glyphs child models, but the A glyph doesn't have child models. I assume this would negatively effect an "O". Am I missing something?

{
  paths: {
    p_1: Line { type: 'line', origin: [Array], end: [Array] },
    p_2: Line { type: 'line', origin: [Array], end: [Array] },
    p_3: Line { type: 'line', origin: [Array], end: [Array] },
    p_4: Line { type: 'line', origin: [Array], end: [Array] },
    p_5: Line { type: 'line', origin: [Array], end: [Array] },
    p_6: Line { type: 'line', origin: [Array], end: [Array] },
    p_7: Line { type: 'line', origin: [Array], end: [Array] },
    p_8: Line { type: 'line', origin: [Array], end: [Array] },
    p_9: Line { type: 'line', origin: [Array], end: [Array] },
    p_10: Line { type: 'line', origin: [Array], end: [Array] },
    p_11: Line { type: 'line', origin: [Array], end: [Array] },
    p_12: Line { type: 'line', origin: [Array], end: [Array] }
  },
  origin: [ 24.90340909090909, 0 ]
}
danmarshall commented 2 years ago

It appears the A character in this font is created with 2 overlapped closed shapes. This is pretty unusual for a font. Typically the author would ensure the character is a union. It’s ok to have multiple closed geometriessuch as an O or “I” but the assumption is they’re not overlapping.

So you’ll need to take the A model and find the chains in it. Then convert the chains to models, then combine the models with a union. I’m mobile right now but you should be able to find these functions in the documentation.

lachlansmith commented 2 years ago

Hi @danmarshall thanks for getting back to me. I managed to resolve the above. I've now encountered a separate issue related to chains. The toPDF method generates path information by finding chains.

MakerJs.model.findChains(scaledModel, function (chains, loose, layer) {
    chains.map(function (chain) {
        if (chain.links.length > 1) {
            var pathData = exporter.chainToSVGPathData(chain, offset);
            doc.path(pathData).stroke(opts.stroke);
        }
...

Post exportation using fill with either pdfkit or pdf-lib results in any outer chain filling over any inner chain.

Screen Shot 2022-06-27 at 8 24 38 pm

As I understand it makerjs is for outlines, and fill is out of scope. However, I'm unaware of any other javascript pdf libraries that support path data and noticed that toPDF likely doesn't support fill for this reason. Generating SVG path data that pdfkit supports seems like an easier option than correcting pdfkit's path/fill methods. My next approach is to use opentype's font.getPaths and makerjs importer with childrenOnPath to use path data supported by pdfkit. Is there a better solution here?

danmarshall commented 2 years ago

Fill is not completely out of scope, because Maker.js is aware of winding order. See https://maker.js.org/docs/working-with-chains/#Find%20multiple%20chains

This might be as simple as adding the options { contain: { alternateDirection: true } } to the .findChains() call within .toPDF(). True that I didn't consider fill in IPDFRenderOptions, but it could (and probably should) be added.

lachlansmith commented 2 years ago

Hi @danmarshall thanks for the suggestion. I've tried combinations of fill rules and { contain: { alternateDirection: true } } to no avail. You've left a TODO for fill support. Perhaps this is already supported elsewhere, toSVGPathData?

//TODO use only chainToSVGPathData instead of circle, so that we can use fill

Ideally, I'd like to use toSVGPathData and pdf-lib instead of pdfkit.

danmarshall commented 2 years ago

Hi @lachlansmith , it makes sense, after thinking about it further, the existing code does not account for the nesting. Since the call you referenced uses chainToSVGPathData which does not descend into any nested/contained chains. I think it should work if the function would crawl the .contains recursively and return a union of all the svg.

lachlansmith commented 2 years ago

Hi @danmarshall thanks for your assistance. I managed to resolve the above by changing the fill rule from the default evenodd to nonzero. I'd would never have known about winding order unless you mentioned it.

const path = MakerJs.exporter.toSVGPathData(model, {
    fillRule: 'nonzero',
});

I'm actually a bit confused why evenodd wasn't working here. The documentation you linked suggests evenodd should not cause fills over enclosed shapes. Changing the fill rule to nonzero seems contrary.

lachlansmith commented 2 years ago

Hi @danmarshall changing the fill rule wasn't the solution here. With almost all fonts I try there always seems to be at least one character not filling correctly.

const path = MakerJs.exporter.toSVGPathData(model, {
    fillRule: 'evenodd',
});

This generates:

Screen Shot 2022-07-06 at 8 29 22 am
const path = MakerJs.exporter.toSVGPathData(model, {
    fillRule: 'nonzero',
});

This generates:

Screen Shot 2022-07-06 at 8 30 30 am

At first, I thought I had this backwards. The documentation you linked suggests those two should be opposite. Screen Shot 2022-07-06 at 9 09 51 am

The toSVGPathData has likely been around for some time so forgive me when I ask is something going wrong here? Every font I try seems to break.

danmarshall commented 2 years ago

For context - this is only happening in the PDF output, correct?

lachlansmith commented 2 years ago

This is happening with toSVGPathData output getting passed to either drawSvgPath from pdf-lib or doc.path().fill() from pdfkit.

lachlansmith commented 2 years ago

Example fonts: Archive.zip

danmarshall commented 2 years ago

Also seems to be happening with some Google fonts: https://github.com/danmarshall/google-font-to-svg-path/issues/30

danmarshall commented 2 years ago

Unfortunately, some fonts are degenerate, regarding each glyph having a closed geometry which is non-overlapping. Let's look at Bodoni Moda:

image

https://danmarshall.github.io/google-font-to-svg-path/?font-select=Bodoni+Moda&font-variant=regular&input-union=false&input-filled=false&input-kerning=true&input-separate=false&input-text=Verb+1234&input-bezier-accuracy=&dxf-units=cm&input-size=300&input-fill=%23000&input-stroke=%23000&input-strokeWidth=0.25mm

Notice that with the glyphs r, 1, 2 and 3 you could use the strategy of combining sub-geometries. But with the e and 4, there is a singular geometry which is closed but is self-overlapping.

This wasn't common some time ago, but now there seem to be many fonts assembled this way. I don't currently have a solution. One idea that springs to mind is to create a process to compare 2 bitmaps of a shape, one rendered with even-odd and the other with nonzero. If they differ, the shape is degenerate. 🤔

danmarshall commented 2 years ago

Fonts seem to be consistent within themselves. Perhaps you can store the fill rule associated with each font, then apply correspondingly?

lachlansmith commented 2 years ago

How does opentype.js approach this? The output of const path = font.getPath("Verb 1234", 0, 0, 32).toPathData(3) consistently fills correctly with both pdf-lib & pdfkit.

    const doc = await PDFDocument.create();

    const font = fs.readFileSync('./examples/font.ttf');

    const f = opentype.parse(font.buffer);
    const path = f.getPath('Verb 1234', 0, 0, 48).toPathData(3);

    const page = doc.addPage([300.5, 300.5]);

    page.drawSvgPath(path, {
        x: 0,
        y: 150.25,
        color: rgb('#000000'),
    });

    fs.writeFileSync('output.pdf', await doc.save());
Screen Shot 2022-07-06 at 10 59 35 am

Unfortunately, I'm not well versed enough with vector graphics to tackle this. I'd like to be. I'm going to have a dig around and see what conclusions can be drawn from the difference between makerjs output and opentype.js output.

lachlansmith commented 2 years ago

Hi @danmarshall this idea is probably just taking an awesome feature and crippling it. But it's an idea nonetheless.

There seems to be an apparent loss of information here. Currently, glyphs are being destructured into paths and nested models. In the case of 4, when the paths and nested models are restructured the path data is no longer a single closed geometry. It's now a collection of lines.

models: {
    'V': {...},
    'e': {...},
    'r': {...},
    'b': {...},
    '1': {...},
    '2': {...},
    '3': {...},
    '4': {
        paths: {
          p_1: [Line],
          p_2: [Line],
          p_3: [Line],
          p_4: [Line],
          p_5: [Line],
          p_6: [Line],
          p_7: [Line],
          p_8: [Line],
          p_9: [Line],
          p_10: [Line],
          p_11: [Line],
          p_12: [Line],
          p_13: [Line]
        },
        origin: [ 98.4807753, -17.3648178 ]
      }
    }

For the purpose of preserving the font author's path data could glyph be introduced?

models: {
  'Verb 1234': {
      paths: {
        p_1: [Glyph],
        p_2: [Glyph],
        p_3: [Glyph],
        p_4: [Glyph],
        p_5: [Glyph],
        p_6: [Glyph],
        p_7: [Glyph],
        p_8: [Glyph]
      },
      origin: [ 98.4807753, -17.3648178 ]
    }
}

If destructuring remains desired, or vice versa, this could be exposed on Text.

MakerJs.models.Text(
    font: opentype.Font, 
    text: string, 
    fontSize: number, 
    destructure?: boolean | undefined        <------
    combine?: boolean | undefined, 
    centerCharacterOrigin?: boolean | undefined, 
    bezierAccuracy?: number | undefined, 
    opentypeOptions?: opentype.RenderOptions | undefined
): MakerJs.models.Text

I can't imagine any other use case than [Glyph], but if it needed to be more general, simply [Path].

danmarshall commented 2 years ago

I had the same idea 😁 but I won't be adding it to the core code anytime soon, I'd need to rationalize it across the entire API first. Meanwhile, remember that your models are plain objects, and you can decorate them as you like. So maybe you can use the raw opentype lib to get the svg path and add it to each glyph model. Also, consider adding a vertical line v to each glyph. Then do your layout. After layout, you can call makerjs.angle.ofLineInDegrees on each v. Then use a package like svgpath to do a rotate transform on the svg path.

lachlansmith commented 2 years ago

Hi @danmarshall thanks for queueing this work up. What does MakerJs' timeline look like? MakerJs' covers all of my other basis. I'd much rather move forward when support is in place. Not asking for a date, but a general idea of when I can revisit this would be fantastic.

danmarshall commented 2 years ago

@lachlansmith the timeline is quite long right now, as this is my fun side project and gets delayed for higher priorities. I have one pull request that is almost 2 years old now 🙁