Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom fonts #421

Merged
merged 11 commits into from
Jul 12, 2021
Merged

Add support for custom fonts #421

merged 11 commits into from
Jul 12, 2021

Conversation

carson-katri
Copy link
Member

Adds a .custom member to Font, which will pass the value to font-family in the DOM renderer.

@carson-katri carson-katri added the SwiftUI compatibility Tokamak API differences with SwiftUI label Jul 8, 2021
@ezraberch
Copy link
Member

  1. Certain characters in the font name, such as ", can break the HTML. It's probably best to sanitize the font names. Not doing so could potentially cause security issues as well.
  2. From https://developer.mozilla.org/en-US/docs/Web/CSS/font-family:

Font family names containing whitespace should be quoted.

You should always include at least one generic family name in a font-family list, since there's no guarantee that any given font is available. This lets the browser select an acceptable fallback font when necessary.

@carson-katri
Copy link
Member Author

I added a Sanitizer protocol to TokamakStaticHTML, and created a sanitizer for CSS strings. Please let me know if I overlooked anything in the sanitizer implementation.

@ezraberch
Copy link
Member

Generic family names are keywords and must not be quoted.

Do we want to allow custom selection of the fallback, or multiple fonts in general? There are several ways this could be done (such as allowing the font name string to contain multiple fonts, allowing an array of font names to be used, or allow chaining of .font() to add fonts rather than replacing).

@carson-katri carson-katri marked this pull request as draft July 8, 2021 17:02
@carson-katri carson-katri added the DOM/HTML renderer Tokamak in the browser label Jul 8, 2021
@carson-katri carson-katri marked this pull request as ready for review July 8, 2021 22:42
@carson-katri
Copy link
Member Author

carson-katri commented Jul 8, 2021

I liked the idea of chaining font modifiers, because it's compatible with SwiftUI.

Now a .font modifier on any View/Text will add to a stack of fonts, which will then be translated into CSS.

Renderers can still choose to ignore the _fontStack environment value and use font instead

Text("Hello, world!")
  .font(.custom("Marker Felt", size: 17))
  .font(.system(.body, design: .serif))

The above snippet will use Marker Felt if available in the UA, or the default serif stack if not:

font-family: 'Marker Felt', Cambria, 'Hoefler Text', Utopia, ...;

I also improved the sanitizer to sanitize identifiers and strings separately.

@carson-katri carson-katri requested a review from a team July 9, 2021 12:51
_fontPath = [newFont] + _fontPath.filter { $0 != newFont }
} else {
_fontPath = []
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this file be split by any chance to avoid the linter warning? Maybe some extensions or types moved to separate files?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll split it into more manageable files.

MaxDesiatov
MaxDesiatov previously approved these changes Jul 9, 2021
Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems legit 👍

Comment on lines 80 to 82
input.starts(with: "'") || input.starts(with: "\"")
&& !input.dropFirst().dropLast().allSatisfy(isStringContent)
&& input.last == input.first
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this function? It isn't called anywhere. Additionally, the logic isn't correct.

Suggested change
input.starts(with: "'") || input.starts(with: "\"")
&& !input.dropFirst().dropLast().allSatisfy(isStringContent)
&& input.last == input.first
(input.starts(with: "'") || input.starts(with: "\""))
&& input.dropFirst().dropLast().allSatisfy(isStringContent)
&& input.last == input.first

/// Sanitizes a quoted string.
enum StringValue: Sanitizer {
static func isStringContent(_ char: Character) -> Bool {
char != "'" && char != "\""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newline and backslash can also cause problems.

Suggested change
char != "'" && char != "\""
!"'\"\n\\".contains(char)

Comment on lines 68 to 70
static func sanitize(_ input: String) -> String {
input.filter(isIdentifierChar)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are additional restrictions for the first character of identifiers. I'm not sure if it really matters here, but I'd at least add a comment in case this code gets reused.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the sanitizers to use the CSS 2.1 grammar.

@@ -64,6 +64,13 @@ struct TextDemo: View {
)
.multilineTextAlignment(alignment)
}
Text("Custom Font")
.font(.custom("\"Marker Felt\"", size: 17))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The font-family generated by this has just the custom font. There should always be a fallback, even if one isn't specified explicitly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a default fallback of .body.

MaxDesiatov
MaxDesiatov previously approved these changes Jul 9, 2021
Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@ezraberch
Copy link
Member

  1. Identifier validation is still not properly handling when the first character is a number. Try adding this test:
    XCTAssertFalse(Sanitizers.CSS.validate(identifier: "1hey-there"))
  2. String sanitize truncates rather than removing the offending characters. For example
    Sanitizers.CSS.sanitize(string: "'hey'there'") -> "\'hey\'"
  3. Is identifier-sanitize still necessary? It only happens if identifier-validate has already succeeded, so I don't think it does anything.

@carson-katri
Copy link
Member Author

carson-katri commented Jul 9, 2021

Resolved 3. Do you have suggestions on how to fix the other 2? I can't figure out why its matching numbers in the identifier parsing.

@ezraberch
Copy link
Member

  1. The reason why it's matching some numbers is because it's matching this part:
    /// '[\240-\377]'
    static let nonAscii: RegularExpression = #"[\240-\377]"#
    The digits which match are 0,1,2,3,4, and 7, so it looks like it's ignoring the backslashes and interpreting that as "2, 4, 0-3, 7, or 7".
    In other words, swift is not recognizing that syntax as a way to denote a range of characters.

  2. What your filter is doing is finding all matches to the regex and merging them together, and ignoring the rest. You've defined a match as including start and end quotes. So 'ab'ed' has one valid match ('ab') so that's what you get. On the other hand, 'ab'c'de' has two valid matches ('ab' and 'de'), so you get 'ab''de'. Anything not part of a match is lost.
    If you remove the requirement that each match includes the start and end quotes, what you're doing should work.

@carson-katri
Copy link
Member Author

Thank you for tracking those issues down! Turns out NSRegularExpression uses \0xxx to specify an Octal character, so that fixed it.

@carson-katri carson-katri requested a review from a team July 10, 2021 18:10
@MaxDesiatov
Copy link
Collaborator

@ezraberch would you have a moment to have another quick look? I personally don't have any objections for this code to be merged, but I appreciate very detailed reviews from you!

Copy link
Member

@ezraberch ezraberch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionality looks good now.

I'm still unsure why Identifier.sanitize and StringValue.validate exist, as they're not called anywhere (other than by tests). The Sanitizer protocol does require them, but that seems unnecessary as well.

@carson-katri
Copy link
Member Author

Yeah it might be unnecessary. I just thought the functionality may be useful in the future when we are sanitizing more areas.

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@carson-katri carson-katri merged commit b6790c5 into main Jul 12, 2021
@carson-katri carson-katri deleted the custom-font branch July 12, 2021 16:10
@MaxDesiatov
Copy link
Collaborator

@ezraberch thanks again for your feedback on this and other PRs and your previous contributions! Would you be interested in joining our maintainers team? This would allow you to give review approvals for example.

@ezraberch
Copy link
Member

Sure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
DOM/HTML renderer Tokamak in the browser SwiftUI compatibility Tokamak API differences with SwiftUI
Development

Successfully merging this pull request may close these issues.

3 participants