diff --git a/classes/format_html.go b/classes/format_html.go
index 6d74337..b1f23ed 100644
--- a/classes/format_html.go
+++ b/classes/format_html.go
@@ -79,12 +79,200 @@ func FormatHTML(src string) (string, error) {
// FormatHTML allow-list and strips what the policy does not allow. HTML the
// policy keeps whole is returned byte for byte. It is the gate for
// content_html supplied without markdown, such as a translation that carries
-// only content_html.
+// only content_html. It is the string half of SanitizeHTMLReport.
func SanitizeHTML(doc string) string {
- out, _ := sanitizeHTML(doc)
+ out, _ := SanitizeHTMLReport(doc)
return out
}
+// maxRemovalNames is how many distinct names an HTMLRemovals list keeps.
+const maxRemovalNames = 10
+
+// HTMLRemovals reports what SanitizeHTMLReport removed from a document.
+// Elements and Attributes are the distinct removed element and attribute
+// names in first-seen order, at most ten each; ElementCount and
+// AttributeCount count every removal, so they stay exact when a list is cut.
+// An attribute counts as removed when the policy dropped it, when it is a
+// repeat of a name the tag already has, or when the policy changed its value
+// (a style attribute that lost a property counts under "style"). Comments
+// counts removed HTML comments. Other is set when the policy changed the
+// document in a way none of the other fields describe.
+type HTMLRemovals struct {
+ Elements []string
+ ElementCount int
+ Attributes []string
+ AttributeCount int
+ Comments int
+ Other bool
+
+ // moreElements and moreAttributes are set when a list was cut.
+ moreElements bool
+ moreAttributes bool
+}
+
+// Empty reports whether nothing was removed, which is exactly when
+// SanitizeHTMLReport returned its input unchanged.
+func (r HTMLRemovals) Empty() bool {
+ return r.ElementCount == 0 && r.AttributeCount == 0 && r.Comments == 0 && !r.Other
+}
+
+func (r *HTMLRemovals) addElement(name string) {
+ r.ElementCount++
+ r.Elements, r.moreElements = addRemovalName(r.Elements, r.moreElements, name)
+}
+
+func (r *HTMLRemovals) addAttribute(name string) {
+ r.AttributeCount++
+ r.Attributes, r.moreAttributes = addRemovalName(r.Attributes, r.moreAttributes, name)
+}
+
+func addRemovalName(names []string, cut bool, name string) ([]string, bool) {
+ if slices.Contains(names, name) {
+ return names, cut
+ }
+ if len(names) >= maxRemovalNames {
+ return names, true
+ }
+ return append(names, name), cut
+}
+
+// SanitizeHTMLReport is SanitizeHTML with a report of what the policy
+// removed. HTML the policy keeps whole is returned byte for byte with an
+// empty report. Otherwise the policy's output is returned and the report is
+// built by aligning the start tags of doc with those of the output in order
+// (the policy only removes, it never adds or reorders): an input tag without
+// a counterpart is a removed element, and an attribute of a matched tag that
+// the output lacks or holds with another value is a removed attribute.
+// Comments are counted on their own. The report is empty exactly when the
+// returned string equals doc.
+func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
+ clean, changed := sanitizeHTML(doc)
+ if !changed || clean == doc {
+ return doc, HTMLRemovals{}
+ }
+ report := removalReport(doc, clean)
+ if report.Empty() {
+ report.Other = true
+ }
+ return clean, report
+}
+
+// reportTag is one start tag of a document as removalReport sees it.
+type reportTag struct {
+ name string
+ raw []string // attribute names in source order, repeats included
+ attrs map[string]string // decoded values, the first of a repeated name
+}
+
+// reportTokens returns doc's start tags and its number of comments.
+func reportTokens(doc string) ([]reportTag, int) {
+ var (
+ tags []reportTag
+ comments int
+ )
+ z := xhtml.NewTokenizer(strings.NewReader(doc))
+ for {
+ tt := z.Next()
+ switch tt {
+ case xhtml.ErrorToken:
+ return tags, comments
+ case xhtml.CommentToken:
+ comments++
+ case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
+ raw := rawAttrNames(z.Raw())
+ t := z.Token()
+ attrs := make(map[string]string, len(t.Attr))
+ for _, a := range t.Attr {
+ key := a.Key
+ if a.Namespace != "" {
+ key = a.Namespace + ":" + a.Key
+ }
+ if _, seen := attrs[key]; !seen {
+ attrs[key] = a.Val
+ }
+ }
+ tags = append(tags, reportTag{name: t.Data, raw: raw, attrs: attrs})
+ }
+ }
+}
+
+// keeps reports whether out can be the policy's output of in: the same
+// element, and every output attribute present in the input with the same
+// value, except style, whose properties the policy filters one by one.
+func (in reportTag) keeps(out reportTag) bool {
+ if in.name != out.name {
+ return false
+ }
+ for key, val := range out.attrs {
+ got, ok := in.attrs[key]
+ if !ok || (got != val && key != "style") {
+ return false
+ }
+ }
+ return true
+}
+
+// removalReport compares doc with the policy's output clean. It aligns the
+// start tags greedily, preferring an output tag whose attributes the input
+// tag can have produced; when that leaves output tags unmatched, it aligns
+// by element name only, which always succeeds when the output's tags are a
+// subsequence of the input's.
+func removalReport(doc, clean string) HTMLRemovals {
+ in, inComments := reportTokens(doc)
+ out, outComments := reportTokens(clean)
+ match := alignTags(in, out, reportTag.keeps)
+ if match == nil {
+ match = alignTags(in, out, func(a, b reportTag) bool { return a.name == b.name })
+ }
+ var r HTMLRemovals
+ for i, tag := range in {
+ j := -1
+ if match != nil {
+ j = match[i]
+ }
+ if j < 0 {
+ r.addElement(tag.name)
+ continue
+ }
+ kept := out[j]
+ seen := make(map[string]bool, len(tag.raw))
+ for _, name := range tag.raw {
+ if seen[name] {
+ r.addAttribute(name) // a repeat the tokenizer dropped
+ continue
+ }
+ seen[name] = true
+ val, ok := kept.attrs[name]
+ if !ok || val != tag.attrs[name] {
+ r.addAttribute(name)
+ }
+ }
+ }
+ if inComments > outComments {
+ r.Comments = inComments - outComments
+ }
+ return r
+}
+
+// alignTags matches every tag of out to a tag of in, in order, with the
+// first input tag for which same holds. The result maps each input index to
+// its output index or -1. It is nil when an output tag stays unmatched.
+func alignTags(in, out []reportTag, same func(in, out reportTag) bool) []int {
+ match := make([]int, len(in))
+ j := 0
+ for i := range in {
+ match[i] = -1
+ if j < len(out) && same(in[i], out[j]) {
+ match[i] = j
+ j++
+ }
+ }
+ if j < len(out) {
+ return nil
+ }
+ return match
+}
+
// FormatTranslation sets attrs["content_html"] for one locale's translated
// post attributes, so a translated content_html is stored under the same
// policy as the host column. When attrs["content"] is not blank, content_html