Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ltml/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,9 +441,11 @@ Attributes are applied to an element in this order (each overrides the previous)
1. **Alias defaults** — `Attrs` map from the matching `*Alias`, if the tag name
resolved through an alias.
2. **Rule attributes** — for each `Rule` whose selector matches the element's
path, attributes are applied in document order (outer scope before inner).
path, attributes from `<style>` blocks (and legacy `<rules>` blocks) are
applied in cascade order: lower `tier`, then lower selector specificity,
then earlier declarations.
3. **Direct XML attributes** — the actual attributes written on the tag in the
document.

This mirrors CSS specificity: inline styles win over rules, which win over
This mirrors a CSS-like cascade: inline styles win over rules, which win over
defaults.
32 changes: 21 additions & 11 deletions ltml/SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ definitions and one or more `<page>` elements.

Both `<ltml>` and `<page>` establish a style scope. Style definitions
(`<font>`, `<pen>`, `<brush>`, `<para>`, `<bullet>`, `<layout>`), aliases
(`<define>`), and rules (`<rules>`) placed inside a `<page>` are visible only
to that page. Definitions placed directly inside `<ltml>` are visible to all
pages. A page can always reference definitions from its parent `<ltml>` scope,
but other pages cannot see definitions made inside a sibling page.
(`<define>`), and selector styles (`<style>`; legacy `<rules>`) placed inside a
`<page>` are visible only to that page. Definitions placed directly inside
`<ltml>` are visible to all pages. A page can always reference definitions from
its parent `<ltml>` scope, but other pages cannot see definitions made inside a
sibling page.

```xml
<ltml>
Expand Down Expand Up @@ -523,19 +524,19 @@ Now `<td>` is equivalent to `<p border="solid" padding="3pt">`.

---

## Rules (CSS-like Selectors)
## Style (CSS-like Selectors)

Apply attributes to elements based on tag name, id, and class selectors.

```xml
<rules>
<style tier="4">
p { font.size: 14; }
p.intro { font.weight: Bold; style.text-align: justify; }
div#footer { margin-top: 20; }
</rules>
</style>
```

Rules use CSS-style selector syntax:
Style blocks use CSS-style selector syntax:

| Pattern | Matches |
|-----------------|---------|
Expand All @@ -546,13 +547,22 @@ Rules use CSS-style selector syntax:
| `div > p` | `<p>` elements that are direct children of a `<div>` |
| `p, span` | All `<p>` and `<span>` elements |

CSS-style `/* ... */` comments are ignored inside `<rules>`. Rules inside
`<!-- XML comments -->` are also parsed, allowing rules to be commented out
CSS-style `/* ... */` comments are ignored inside `<style>`. Rules inside
`<!-- XML comments -->` are also parsed, allowing selectors to be commented out
with nested comment delimiters.

`<style>` accepts an optional `tier` attribute. Higher tiers override lower
tiers before specificity and source order are considered. Default tiers are:

- document-scope `<ltml><style>`: `0`
- page-scope `<page><style>`: `1`

The legacy `<rules>` tag remains supported for compatibility, but `<style>` is
the preferred tag going forward.

Attribute priority (lowest to highest):
1. Default attributes from an alias (`<define>`)
2. Attributes from matching rules
2. Attributes from matching rules, ordered by tier, then specificity, then declaration order
3. Direct XML attributes on the element

---
Expand Down
8 changes: 8 additions & 0 deletions ltml/ltml.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Doc struct {
stack []any
scopes []HasScope
rootScope Scope // per-document root scope; parent = &defaultScope
parseErr error
}

func (doc *Doc) Parse(b []byte) error {
Expand Down Expand Up @@ -59,6 +60,9 @@ func (doc *Doc) ParseReader(r io.Reader) error {
traceComment(t)
doc.comment(t)
}
if doc.parseErr != nil {
return doc.parseErr
}
}
return nil
}
Expand Down Expand Up @@ -105,7 +109,10 @@ func (doc *Doc) startElement(elem xml.StartElement) {
}
}
if d, ok := e.(*StdDocument); ok {
d.Scope.defaultRuleTier = 0
doc.ltmls = append(doc.ltmls, d)
} else if page, ok := e.(*StdPage); ok {
page.Scope.defaultRuleTier = 1
}
doc.push(e)

Expand Down Expand Up @@ -176,6 +183,7 @@ func (doc *Doc) startElement(elem xml.StartElement) {
}
if rules, ok := e.(*Rules); ok {
if err := doc.scope().AddRules(rules); err != nil {
doc.parseErr = err
debugf("Adding rules: %s\n", err)
}
}
Expand Down
81 changes: 75 additions & 6 deletions ltml/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,64 @@
package ltml

import (
"fmt"
"regexp"
"strconv"
"strings"
)

type Specificity struct {
IDs int
Classes int
Tags int
}

func (s Specificity) Compare(other Specificity) int {
if s.IDs != other.IDs {
return cmpInt(s.IDs, other.IDs)
}
if s.Classes != other.Classes {
return cmpInt(s.Classes, other.Classes)
}
return cmpInt(s.Tags, other.Tags)
}

// Rule associates a CSS-like selector with a set of attributes. When an element's
// path matches the selector, the attributes are applied to that element before any
// directly-specified attributes (so direct attributes take precedence).
type Rule struct {
Selector string
SelectorRegexp *regexp.Regexp
Attrs map[string]string
Tier int
Specificity Specificity
Order int
}

// NewRule creates a Rule for the given selector string and attribute map. The
// selector is compiled into a regexp once so matching during document layout is
// fast. See selectors.go for supported selector syntax.
func NewRule(selector string, attrs map[string]string) *Rule {
func NewRule(selector string, attrs map[string]string, tier, order int) *Rule {
return &Rule{
Selector: selector,
SelectorRegexp: regexpForSelector(selector),
Attrs: attrs,
Tier: tier,
Specificity: specificityForSelector(selector),
Order: order,
}
}

// Rules is the in-memory representation of a <rules> block. It holds zero or
// more Rule values parsed from CSS-like text and is registered with the enclosing
// Scope so that elements can be matched against them during parsing.
// Rules is the in-memory representation of a <style> or legacy <rules> block.
// It holds zero or more Rule values parsed from CSS-like text and is registered
// with the enclosing Scope so that elements can be matched against them during
// parsing.
type Rules struct {
rules []*Rule
rules []*Rule
tier int
tierExplicit bool
nextRuleOrder int
parseErr error
}

// AddComment satisfies the XML comment handler interface. Rule declarations may
Expand All @@ -53,13 +82,50 @@ var reRule = regexp.MustCompile(`\s*([^\{]+?)\s*\{([^\}]+)\}`)
// Multiple declarations may appear in a single call. Whitespace around selectors
// and values is trimmed automatically. CSS-style block comments are ignored.
func (r *Rules) AddText(text string) {
if r.parseErr != nil {
return
}
text = stripCSSComments(text)
matches := reRule.FindAllStringSubmatch(text, -1)
for _, m := range matches {
r.rules = append(r.rules, NewRule(m[1], attrsMapFromString(m[2])))
for _, selector := range splitRuleSelectors(m[1]) {
r.rules = append(r.rules, NewRule(selector, attrsMapFromString(m[2]), r.tier, r.nextRuleOrder))
r.nextRuleOrder++
}
}
}

func (r *Rules) SetAttrs(attrs map[string]string) {
tierText, ok := attrs["tier"]
if !ok || tierText == "" {
return
}
tier, err := strconv.Atoi(strings.TrimSpace(tierText))
if err != nil {
r.parseErr = fmt.Errorf("invalid rules tier %q", tierText)
return
}
if tier < 0 {
r.parseErr = fmt.Errorf("invalid rules tier %q: tier must be >= 0", tierText)
return
}
r.tier = tier
r.tierExplicit = true
}

func (r *Rules) ensureTier(defaultTier int) error {
if r.parseErr != nil {
return r.parseErr
}
if !r.tierExplicit {
r.tier = defaultTier
for _, rule := range r.rules {
rule.Tier = defaultTier
}
}
return nil
}

var reAttrs = regexp.MustCompile(`\s*([^:]+)\s*:\s*([^;]+)\s*;?`)

// attrsMapFromString parses a semicolon-separated list of "key: value" pairs
Expand All @@ -79,5 +145,8 @@ func stripCSSComments(s string) string {
}

func init() {
registerTag(DefaultSpace, "style", func() any { return &Rules{} })
registerTag(DefaultSpace, "rules", func() any { return &Rules{} })
}

var _ HasAttrs = (*Rules)(nil)
Loading
Loading