diff --git a/CHANGES.md b/CHANGES.md index f7b95172..f025d97d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -38,6 +38,10 @@ ### Parsing +- `shape-outside` reads its whole grammar. Only `none`, `circle()`, a non-empty + `inset()` and the CSS-wide keywords were accepted, so `margin-box`, + `circle(50%) content-box`, `url(shape.png)` and every other basic shape were + rejected and the declaration dropped - An identifier takes any code point at or above U+0080, so a selector such as `.text-↗` parses. `Css.of_string` takes `?enforce_spec` and `cascade` takes `--enforce-spec` to restrict identifiers to the CSS Syntax 3 range list; diff --git a/lib/declaration.ml b/lib/declaration.ml index d2637bc2..d7faa84e 100644 --- a/lib/declaration.ml +++ b/lib/declaration.ml @@ -579,28 +579,50 @@ let read_text_decoration_lines t = if duplicates lines then Cursor.err_invalid t "duplicate text-decoration-line"; lines +(* CSS Shapes 1 sec. 2.2: [] is [ | margin-box], so the + three SVG boxes [] adds are not valid here. *) +let check_shape_box t (box : clip_geometry_box) = + match box with + | Margin_box | Border_box | Padding_box | Content_box -> () + | Fill_box | Stroke_box | View_box -> + Cursor.err_invalid t "shape-outside takes a " + +(* [read_clip_path] reads [ || ], the same double-bar + pair shape-outside uses, along with [none], [url()] and the CSS-wide + keywords. Shapes 1 sec. 2 differs on two points, checked here: the box is a + [], and everything outside [] is an alternative to + the pair rather than a member of it. *) +let check_shape_outside_shape t = + let is_basic_shape (shape : clip_path) = + match shape with + | Clip_path_inset _ | Clip_path_circle _ | Clip_path_ellipse _ + | Clip_path_polygon _ | Clip_path_path _ | Clip_path_shape _ + | Clip_path_xywh _ | Clip_path_rect _ -> + true + (* A shape the [clip_path] reader kept verbatim as spec-invalid: the raw + text survives here too rather than costing the whole declaration. *) + | Invalid _ -> true + | _ -> false + in + match read_clip_path t with + | Clip_path_box box -> check_shape_box t box + | Clip_path_with_box { shape; box; _ } when is_basic_shape shape -> + check_shape_box t box + | Clip_path_with_box _ -> + Cursor.err_invalid t "shape-outside pairs a with a shape" + | _ -> () + +(* CSS Shapes 1 sec. 2: [shape-outside] is [none | [ || + ] | ]. The value is the raw source text ([Shape_outside : + string property]): typing it would mean a sum of the [clip_path] shapes and + the whole [background_image] type for [], so the reader validates the + grammar and hands the text back verbatim. *) let read_shape_outside t = let raw = Cursor.lookahead (Cursor.consume_to_decl_end ~trim:true) t in - let accept_single () = - Cursor.skip t; - Cursor.expect_eof t; - raw - in - let accept_var () = - let _ : string var = - Values.read_var - (fun inner -> Cursor.consume_remaining_as_string ~trim:true inner) - t - in - Cursor.expect_eof t; - raw + let read_shape_image t = + ignore (read_background_image t : background_image) in - match Cursor.peek t with - | Some (Component.Preserved { kind = Token.Ident keyword; _ }) - when Properties.is_css_wide_keyword keyword -> - accept_single () - | Some (Component.Preserved { kind = Token.Ident "none"; _ }) -> - accept_single () + (match Cursor.peek t with | Some (Component.Func { node = { name = "var"; terminated = true; arguments = []; _ }; _ }) @@ -608,19 +630,22 @@ let read_shape_outside t = Cursor.err_invalid t "empty var()" | Some (Component.Func { node = { name = "var"; terminated = true; _ }; _ }) -> - accept_var () - | Some (Component.Func { node = { name = "circle"; terminated; _ }; _ }) - when terminated -> - (* CSS Shapes 1 section 3.1: [circle()] is valid (both [] - and [at ] are optional). *) - accept_single () - | Some - (Component.Func { node = { name = "inset"; arguments; terminated }; _ }) - when terminated && arguments <> [] -> - accept_single () - | Some (Component.Func { node = { name = "inset"; _ }; _ }) -> - Cursor.err_invalid t "empty basic shape" - | _ -> Cursor.err_invalid t ("invalid shape-outside: " ^ raw) + let _ : string var = + Values.read_var + (fun inner -> Cursor.consume_remaining_as_string ~trim:true inner) + t + in + () + | _ -> + Cursor.one_of + [ + check_shape_outside_shape; + read_shape_image; + (fun t -> Cursor.err_invalid t ("invalid shape-outside: " ^ raw)); + ] + t); + Cursor.expect_eof t; + raw let read_grid_template_list t = read_grid_template t diff --git a/test/test_declaration.ml b/test/test_declaration.ml index 01a615d2..979abf2c 100644 --- a/test/test_declaration.ml +++ b/test/test_declaration.ml @@ -2524,6 +2524,68 @@ let scroll_margin_negative_sheet () = "a{scroll-margin-inline-start:-1px}"; ] +(* A declaration the reader rejects is dropped from the sheet with nothing but a + warning, so every reader gap needs a whole-sheet pin too. *) +let check_sheet_roundtrip name css = + match Css.of_string ~strict:true css with + | Ok { stylesheet; _ } -> + Alcotest.(check string) + (name ^ " sheet roundtrip") + css + (String.trim (Css.to_string ~minify:true stylesheet)) + | Error e -> Alcotest.failf "%s: %s" css (Error.to_string e) + +(* CSS Shapes 1 sec. 2: [shape-outside] is [none | [ || + ] | ]. The reader only looked at the first component and + only knew [none], [circle()] and [inset()] there, so a reference box, a + box/shape pair, the other basic shapes and an image were all rejected and the + declaration dropped. *) +let shape_outside_grammar () = + List.iter + (fun css -> check_declaration ~roundtrip:true css) + [ + "shape-outside:none"; + "shape-outside:inherit"; + "shape-outside:var(--shape)"; + (* *) + "shape-outside:circle(50%)"; + "shape-outside:circle()"; + "shape-outside:circle(50% at 20% 30%)"; + "shape-outside:ellipse(closest-side farthest-side at 10px 20px)"; + "shape-outside:inset(10px round 2px)"; + "shape-outside:polygon(0 0,100% 0,100% 100%)"; + "shape-outside:xywh(0 0 100% 100%)"; + (* on its own, and either order in the [||] pair *) + "shape-outside:margin-box"; + "shape-outside:content-box"; + "shape-outside:circle() border-box"; + "shape-outside:padding-box circle(50%)"; + (* *) + "shape-outside:url(shape.png)"; + "shape-outside:linear-gradient(red,blue)"; + ]; + (* Controls: an unknown keyword is no part of the grammar, [none] and a box + are alternatives rather than a [||] pair, the box appears once, and + [] excludes the three SVG boxes [] adds. *) + List.iter + (neg_cursor read_declaration) + [ + "shape-outside:not-a-shape"; + "shape-outside:none margin-box"; + "shape-outside:margin-box border-box"; + "shape-outside:circle(50%) fill-box"; + ] + +let shape_outside_sheet () = + List.iter + (check_sheet_roundtrip "shape-outside") + [ + "a{shape-outside:none}"; + "a{shape-outside:margin-box}"; + "a{shape-outside:circle(50%) content-box}"; + "a{shape-outside:url(shape.png)}"; + ] + let declaration_tests = [ (* Core declaration type testing *) @@ -2591,6 +2653,8 @@ let declaration_tests = test_case "scroll-margin negative lengths" `Quick scroll_margin_negative; test_case "scroll-margin negative lengths (sheet)" `Quick scroll_margin_negative_sheet; + test_case "shape-outside grammar" `Quick shape_outside_grammar; + test_case "shape-outside grammar (sheet)" `Quick shape_outside_sheet; (* Spec details and edge cases *) test_case "CSS-wide keywords" `Quick css_wide_keywords; test_case "spec cascade 3 shorthand properties" `Quick diff --git a/test/test_optimize.ml b/test/test_optimize.ml index b20a41ca..3243a306 100644 --- a/test/test_optimize.ml +++ b/test/test_optimize.ml @@ -1517,14 +1517,16 @@ let normalize_pairs = collapse it to one canonical node and pp alone must stay a fixed point. (Whether pp keeps these textually distinct depends on which forms the parser canonicalizes, so that side is asserted only for the colour/calc pairs, where - the node distinction is certain.) *) + the node distinction is certain.) The basic-shape pairs use [clip-path]: + [shape-outside] holds its value as raw text, so nothing about it is + normalized. *) let gradient_shape_pairs = [ ( "a{background:linear-gradient(to top,red,blue)}", "a{background:linear-gradient(0deg,red,blue)}" ); ("a{clip-path:circle(closest-side at center)}", "a{clip-path:circle()}"); - ( "a{shape-outside:ellipse(closest-side closest-side at center)}", - "a{shape-outside:ellipse()}" ); + ( "a{clip-path:ellipse(closest-side closest-side at center)}", + "a{clip-path:ellipse()}" ); ] let assert_pp_keeps_distinct pairs =