Skip an unrenderable point anchor instead of raising on it

e7d9f29 · Gabriel Morell · 2026-09-16 01:52

28 files +677 -11
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/diogramos/app/canvas/DocumentOps.kt
+10 −9
@@ -289,7 +289,6 @@ object DocumentOps {
289 289 return "invalid_connector"
290 290 }
291 291 if (!elements.containsKey(fromId) || !elements.containsKey(toId)) return "missing_endpoint"
292 − if (!validAnchor(from) || !validAnchor(to)) return "invalid_anchor"
293 292
294 293 connector["routing"]?.stringOrNull()
295 294 ?.let { if (it !in CONNECTOR_ROUTINGS) return "invalid_routing" }
@@ -312,14 +311,16 @@ object DocumentOps {
312 311 return "invalid_connector"
313 312 }
314 313
315 − private fun validAnchor(anchor: JsonObject): Boolean {
316 − val side = anchor["anchor"]?.stringOrNull() ?: return true
317 − if (side in ANCHORS) return true
318 − // A "point" anchor is only valid with coordinates attached.
319 − return side == "point" &&
320 − anchor["x"]?.doubleOrNull() != null &&
321 − anchor["y"]?.doubleOrNull() != null
322 − }
314 + /**
315 + * Mirrors `Document.valid_anchor?/1`, laxness included: every
316 + * map-shaped anchor is accepted, unrecognised anchor names and a
317 + * "point" without coordinates among them. A client stricter than the
318 + * server refuses edits that work on the web, which is worse than
319 + * accepting a shape that simply won't draw — `ConnectorGeometry`
320 + * skips an unrenderable connector on both sides.
321 + */
322 + @Suppress("UNUSED_PARAMETER")
323 + private fun validAnchor(anchor: JsonObject): Boolean = true
323 324
324 325 // --- JSON helpers ------------------------------------------------------
325 326
modified android/app/src/test/resources/document_ops.json
+565 −0
@@ -3364,6 +3364,571 @@
3364 3364 "type": "update_connector"
3365 3365 }
3366 3366 },
3367 + {
3368 + "document": {
3369 + "connectors": {
3370 + "k1": {
3371 + "color": "base-content",
3372 + "dash": "solid",
3373 + "end_gap": 6,
3374 + "from": {
3375 + "anchor": "e",
3376 + "element": "a"
3377 + },
3378 + "id": "k1",
3379 + "label": "",
3380 + "marker_end": "arrow",
3381 + "marker_start": "none",
3382 + "routing": "orthogonal",
3383 + "start_gap": 6,
3384 + "stroke_width": 2,
3385 + "to": {
3386 + "anchor": "w",
3387 + "element": "b"
3388 + }
3389 + },
3390 + "k2": {
3391 + "color": "base-content",
3392 + "dash": "solid",
3393 + "end_gap": 6,
3394 + "from": {
3395 + "anchor": "s",
3396 + "element": "b"
3397 + },
3398 + "id": "k2",
3399 + "label": "",
3400 + "marker_end": "arrow",
3401 + "marker_start": "none",
3402 + "routing": "curve",
3403 + "start_gap": 6,
3404 + "stroke_width": 2,
3405 + "to": {
3406 + "anchor": "n",
3407 + "element": "c"
3408 + }
3409 + }
3410 + },
3411 + "elements": {
3412 + "a": {
3413 + "h": 60,
3414 + "id": "a",
3415 + "label": "A",
3416 + "style": {
3417 + "fill": "primary",
3418 + "stroke": "base-content",
3419 + "stroke_width": 2
3420 + },
3421 + "type": "rect",
3422 + "w": 100,
3423 + "x": 0,
3424 + "y": 0
3425 + },
3426 + "b": {
3427 + "cx": 200,
3428 + "cy": 100,
3429 + "id": "b",
3430 + "r": 30,
3431 + "type": "circle"
3432 + },
3433 + "c": {
3434 + "h": 20,
3435 + "id": "c",
3436 + "type": "text",
3437 + "w": 80,
3438 + "x": 10,
3439 + "y": 200
3440 + }
3441 + },
3442 + "order": [
3443 + "a",
3444 + "b",
3445 + "c"
3446 + ]
3447 + },
3448 + "name": "update connector unknown anchor",
3449 + "ok": true,
3450 + "op": {
3451 + "id": "k1",
3452 + "patch": {
3453 + "from": {
3454 + "anchor": "northwest"
3455 + }
3456 + },
3457 + "type": "update_connector"
3458 + },
3459 + "result": {
3460 + "connectors": {
3461 + "k1": {
3462 + "color": "base-content",
3463 + "dash": "solid",
3464 + "end_gap": 6,
3465 + "from": {
3466 + "anchor": "northwest",
3467 + "element": "a"
3468 + },
3469 + "id": "k1",
3470 + "label": "",
3471 + "marker_end": "arrow",
3472 + "marker_start": "none",
3473 + "routing": "orthogonal",
3474 + "start_gap": 6,
3475 + "stroke_width": 2,
3476 + "to": {
3477 + "anchor": "w",
3478 + "element": "b"
3479 + }
3480 + },
3481 + "k2": {
3482 + "color": "base-content",
3483 + "dash": "solid",
3484 + "end_gap": 6,
3485 + "from": {
3486 + "anchor": "s",
3487 + "element": "b"
3488 + },
3489 + "id": "k2",
3490 + "label": "",
3491 + "marker_end": "arrow",
3492 + "marker_start": "none",
3493 + "routing": "curve",
3494 + "start_gap": 6,
3495 + "stroke_width": 2,
3496 + "to": {
3497 + "anchor": "n",
3498 + "element": "c"
3499 + }
3500 + }
3501 + },
3502 + "elements": {
3503 + "a": {
3504 + "h": 60,
3505 + "id": "a",
3506 + "label": "A",
3507 + "style": {
3508 + "fill": "primary",
3509 + "stroke": "base-content",
3510 + "stroke_width": 2
3511 + },
3512 + "type": "rect",
3513 + "w": 100,
3514 + "x": 0,
3515 + "y": 0
3516 + },
3517 + "b": {
3518 + "cx": 200,
3519 + "cy": 100,
3520 + "id": "b",
3521 + "r": 30,
3522 + "type": "circle"
3523 + },
3524 + "c": {
3525 + "h": 20,
3526 + "id": "c",
3527 + "type": "text",
3528 + "w": 80,
3529 + "x": 10,
3530 + "y": 200
3531 + }
3532 + },
3533 + "order": [
3534 + "a",
3535 + "b",
3536 + "c"
3537 + ]
3538 + }
3539 + },
3540 + {
3541 + "document": {
3542 + "connectors": {
3543 + "k1": {
3544 + "color": "base-content",
3545 + "dash": "solid",
3546 + "end_gap": 6,
3547 + "from": {
3548 + "anchor": "e",
3549 + "element": "a"
3550 + },
3551 + "id": "k1",
3552 + "label": "",
3553 + "marker_end": "arrow",
3554 + "marker_start": "none",
3555 + "routing": "orthogonal",
3556 + "start_gap": 6,
3557 + "stroke_width": 2,
3558 + "to": {
3559 + "anchor": "w",
3560 + "element": "b"
3561 + }
3562 + },
3563 + "k2": {
3564 + "color": "base-content",
3565 + "dash": "solid",
3566 + "end_gap": 6,
3567 + "from": {
3568 + "anchor": "s",
3569 + "element": "b"
3570 + },
3571 + "id": "k2",
3572 + "label": "",
3573 + "marker_end": "arrow",
3574 + "marker_start": "none",
3575 + "routing": "curve",
3576 + "start_gap": 6,
3577 + "stroke_width": 2,
3578 + "to": {
3579 + "anchor": "n",
3580 + "element": "c"
3581 + }
3582 + }
3583 + },
3584 + "elements": {
3585 + "a": {
3586 + "h": 60,
3587 + "id": "a",
3588 + "label": "A",
3589 + "style": {
3590 + "fill": "primary",
3591 + "stroke": "base-content",
3592 + "stroke_width": 2
3593 + },
3594 + "type": "rect",
3595 + "w": 100,
3596 + "x": 0,
3597 + "y": 0
3598 + },
3599 + "b": {
3600 + "cx": 200,
3601 + "cy": 100,
3602 + "id": "b",
3603 + "r": 30,
3604 + "type": "circle"
3605 + },
3606 + "c": {
3607 + "h": 20,
3608 + "id": "c",
3609 + "type": "text",
3610 + "w": 80,
3611 + "x": 10,
3612 + "y": 200
3613 + }
3614 + },
3615 + "order": [
3616 + "a",
3617 + "b",
3618 + "c"
3619 + ]
3620 + },
3621 + "name": "insert connector with an unknown anchor",
3622 + "ok": true,
3623 + "op": {
3624 + "connector": {
3625 + "from": {
3626 + "anchor": "sideways",
3627 + "element": "a"
3628 + },
3629 + "id": "k5",
3630 + "to": {
3631 + "element": "c"
3632 + }
3633 + },
3634 + "type": "insert_connector"
3635 + },
3636 + "result": {
3637 + "connectors": {
3638 + "k1": {
3639 + "color": "base-content",
3640 + "dash": "solid",
3641 + "end_gap": 6,
3642 + "from": {
3643 + "anchor": "e",
3644 + "element": "a"
3645 + },
3646 + "id": "k1",
3647 + "label": "",
3648 + "marker_end": "arrow",
3649 + "marker_start": "none",
3650 + "routing": "orthogonal",
3651 + "start_gap": 6,
3652 + "stroke_width": 2,
3653 + "to": {
3654 + "anchor": "w",
3655 + "element": "b"
3656 + }
3657 + },
3658 + "k2": {
3659 + "color": "base-content",
3660 + "dash": "solid",
3661 + "end_gap": 6,
3662 + "from": {
3663 + "anchor": "s",
3664 + "element": "b"
3665 + },
3666 + "id": "k2",
3667 + "label": "",
3668 + "marker_end": "arrow",
3669 + "marker_start": "none",
3670 + "routing": "curve",
3671 + "start_gap": 6,
3672 + "stroke_width": 2,
3673 + "to": {
3674 + "anchor": "n",
3675 + "element": "c"
3676 + }
3677 + },
3678 + "k5": {
3679 + "color": "base-content",
3680 + "dash": "solid",
3681 + "end_gap": 6,
3682 + "from": {
3683 + "anchor": "sideways",
3684 + "element": "a"
3685 + },
3686 + "id": "k5",
3687 + "label": "",
3688 + "marker_end": "arrow",
3689 + "marker_start": "none",
3690 + "routing": "orthogonal",
3691 + "start_gap": 6,
3692 + "stroke_width": 2,
3693 + "to": {
3694 + "element": "c"
3695 + }
3696 + }
3697 + },
3698 + "elements": {
3699 + "a": {
3700 + "h": 60,
3701 + "id": "a",
3702 + "label": "A",
3703 + "style": {
3704 + "fill": "primary",
3705 + "stroke": "base-content",
3706 + "stroke_width": 2
3707 + },
3708 + "type": "rect",
3709 + "w": 100,
3710 + "x": 0,
3711 + "y": 0
3712 + },
3713 + "b": {
3714 + "cx": 200,
3715 + "cy": 100,
3716 + "id": "b",
3717 + "r": 30,
3718 + "type": "circle"
3719 + },
3720 + "c": {
3721 + "h": 20,
3722 + "id": "c",
3723 + "type": "text",
3724 + "w": 80,
3725 + "x": 10,
3726 + "y": 200
3727 + }
3728 + },
3729 + "order": [
3730 + "a",
3731 + "b",
3732 + "c"
3733 + ]
3734 + }
3735 + },
3736 + {
3737 + "document": {
3738 + "connectors": {
3739 + "k1": {
3740 + "color": "base-content",
3741 + "dash": "solid",
3742 + "end_gap": 6,
3743 + "from": {
3744 + "anchor": "e",
3745 + "element": "a"
3746 + },
3747 + "id": "k1",
3748 + "label": "",
3749 + "marker_end": "arrow",
3750 + "marker_start": "none",
3751 + "routing": "orthogonal",
3752 + "start_gap": 6,
3753 + "stroke_width": 2,
3754 + "to": {
3755 + "anchor": "w",
3756 + "element": "b"
3757 + }
3758 + },
3759 + "k2": {
3760 + "color": "base-content",
3761 + "dash": "solid",
3762 + "end_gap": 6,
3763 + "from": {
3764 + "anchor": "s",
3765 + "element": "b"
3766 + },
3767 + "id": "k2",
3768 + "label": "",
3769 + "marker_end": "arrow",
3770 + "marker_start": "none",
3771 + "routing": "curve",
3772 + "start_gap": 6,
3773 + "stroke_width": 2,
3774 + "to": {
3775 + "anchor": "n",
3776 + "element": "c"
3777 + }
3778 + }
3779 + },
3780 + "elements": {
3781 + "a": {
3782 + "h": 60,
3783 + "id": "a",
3784 + "label": "A",
3785 + "style": {
3786 + "fill": "primary",
3787 + "stroke": "base-content",
3788 + "stroke_width": 2
3789 + },
3790 + "type": "rect",
3791 + "w": 100,
3792 + "x": 0,
3793 + "y": 0
3794 + },
3795 + "b": {
3796 + "cx": 200,
3797 + "cy": 100,
3798 + "id": "b",
3799 + "r": 30,
3800 + "type": "circle"
3801 + },
3802 + "c": {
3803 + "h": 20,
3804 + "id": "c",
3805 + "type": "text",
3806 + "w": 80,
3807 + "x": 10,
3808 + "y": 200
3809 + }
3810 + },
3811 + "order": [
3812 + "a",
3813 + "b",
3814 + "c"
3815 + ]
3816 + },
3817 + "name": "insert connector with a point anchor missing coordinates",
3818 + "ok": true,
3819 + "op": {
3820 + "connector": {
3821 + "from": {
3822 + "anchor": "point",
3823 + "element": "a"
3824 + },
3825 + "id": "k6",
3826 + "to": {
3827 + "element": "c"
3828 + }
3829 + },
3830 + "type": "insert_connector"
3831 + },
3832 + "result": {
3833 + "connectors": {
3834 + "k1": {
3835 + "color": "base-content",
3836 + "dash": "solid",
3837 + "end_gap": 6,
3838 + "from": {
3839 + "anchor": "e",
3840 + "element": "a"
3841 + },
3842 + "id": "k1",
3843 + "label": "",
3844 + "marker_end": "arrow",
3845 + "marker_start": "none",
3846 + "routing": "orthogonal",
3847 + "start_gap": 6,
3848 + "stroke_width": 2,
3849 + "to": {
3850 + "anchor": "w",
3851 + "element": "b"
3852 + }
3853 + },
3854 + "k2": {
3855 + "color": "base-content",
3856 + "dash": "solid",
3857 + "end_gap": 6,
3858 + "from": {
3859 + "anchor": "s",
3860 + "element": "b"
3861 + },
3862 + "id": "k2",
3863 + "label": "",
3864 + "marker_end": "arrow",
3865 + "marker_start": "none",
3866 + "routing": "curve",
3867 + "start_gap": 6,
3868 + "stroke_width": 2,
3869 + "to": {
3870 + "anchor": "n",
3871 + "element": "c"
3872 + }
3873 + },
3874 + "k6": {
3875 + "color": "base-content",
3876 + "dash": "solid",
3877 + "end_gap": 6,
3878 + "from": {
3879 + "anchor": "point",
3880 + "element": "a"
3881 + },
3882 + "id": "k6",
3883 + "label": "",
3884 + "marker_end": "arrow",
3885 + "marker_start": "none",
3886 + "routing": "orthogonal",
3887 + "start_gap": 6,
3888 + "stroke_width": 2,
3889 + "to": {
3890 + "element": "c"
3891 + }
3892 + }
3893 + },
3894 + "elements": {
3895 + "a": {
3896 + "h": 60,
3897 + "id": "a",
3898 + "label": "A",
3899 + "style": {
3900 + "fill": "primary",
3901 + "stroke": "base-content",
3902 + "stroke_width": 2
3903 + },
3904 + "type": "rect",
3905 + "w": 100,
3906 + "x": 0,
3907 + "y": 0
3908 + },
3909 + "b": {
3910 + "cx": 200,
3911 + "cy": 100,
3912 + "id": "b",
3913 + "r": 30,
3914 + "type": "circle"
3915 + },
3916 + "c": {
3917 + "h": 20,
3918 + "id": "c",
3919 + "type": "text",
3920 + "w": 80,
3921 + "x": 10,
3922 + "y": 200
3923 + }
3924 + },
3925 + "order": [
3926 + "a",
3927 + "b",
3928 + "c"
3929 + ]
3930 + }
3931 + },
3367 3932 {
3368 3933 "document": {
3369 3934 "connectors": {
modified docs/android.md
+16 −0
@@ -624,6 +624,22 @@ action that is always refused is worse than no button at all.
624 624 with daisyUI typography; Compose text metrics will not match exactly.
625 625 Wrapping and vertical centering are the likely visible differences. Do we
626 626 accept "close", or does the server need to hand back measured line boxes?
627 +- **Anchor validation is looser than it looks.** `Document.valid_anchor?/1`
628 + ends in a catch-all `%{}` clause, so any map-shaped anchor is accepted —
629 + an unrecognised name like `"northwest"`, or `"point"` with no
630 + coordinates. That last one used to raise `ArithmeticError` inside
631 + `ConnectorGeometry.render/2` (nil coordinates reaching the gap
632 + arithmetic), which took down every render of the canvas holding it.
633 + Harmless while the editor's own UI was the only thing writing ops;
634 + reachable by any client once `/api/v1` and the channel existed.
635 +
636 + `render/2` now skips such a connector, the way it already skips one
637 + whose endpoint is gone, and the Kotlin port matches the server's
638 + leniency rather than being stricter than it — a client that refuses
639 + what the server accepts rejects edits that work on the web. Tightening
640 + `valid_anchor?/1` to reject these at the door is the other half, and
641 + would mean deciding what to do about any already stored.
642 +
627 643 - **`set_z_order` races.** The op replaces the entire `order` list, so two
628 644 peers reordering at once means one silently wins with a stale list. Same
629 645 bug exists on web today; worth noting, not worth fixing in this scope.
modified lib/diogramos/diagrams/connector_geometry.ex
+14 −1
@@ -76,7 +76,7 @@ defmodule Diogramos.Diagrams.ConnectorGeometry do
76 76
77 77 element ->
78 78 case Map.get(anchor, "anchor", "auto") do
79 − "point" -> {:ok, {anchor["x"], anchor["y"]}, {0, 0}}
79 + "point" -> point_anchor(anchor)
80 80 a -> point_for_anchor(element, a, anchor, document)
81 81 end
82 82 end
@@ -84,6 +84,19 @@ defmodule Diogramos.Diagrams.ConnectorGeometry do
84 84
85 85 defp resolve_endpoint(_, _), do: :error
86 86
87 + # A "point" anchor without usable coordinates is skipped rather than
88 + # rendered. `Document.valid_anchor?/1` lets one through -- its
89 + # catch-all map clause accepts any anchor that isn't a well-formed
90 + # point -- and the arithmetic below would then raise on nil, taking
91 + # down whatever is drawing the canvas. Now reachable from /api/v1 and
92 + # the channel, where the op comes from a client rather than from the
93 + # editor's own UI.
94 + defp point_anchor(%{"x" => x, "y" => y}) when is_number(x) and is_number(y) do
95 + {:ok, {x, y}, {0, 0}}
96 + end
97 +
98 + defp point_anchor(_), do: :error
99 +
87 100 defp point_for_anchor(element, "auto", anchor_def, document) do
88 101 other_id = anchor_def["_other"] || nil
89 102 bbox = bbox(element)
modified lib/diogramos/diagrams/document.ex
+3 −1
@@ -362,8 +362,10 @@ defmodule Diogramos.Diagrams.Document do
362 362 defp valid_anchor?(%{"anchor" => "point", "x" => x, "y" => y})
363 363 when is_number(x) and is_number(y), do: :ok
364 364
365 + # Any other map is accepted: an anchor with no "anchor" key defaults to
366 + # "auto" downstream. Note this also lets an unrecognised anchor string
367 + # through -- see the note in docs/android.md section 9.
365 368 defp valid_anchor?(%{}), do: :ok
366 − defp valid_anchor?(_), do: {:error, :invalid_anchor}
367 369
368 370 defp validate_connector_style(connector) do
369 371 routing = Map.get(connector, "routing", "orthogonal")
modified lib/mix/tasks/diogramos.android_fixtures.ex
+27 −0
@@ -365,6 +365,33 @@ defmodule Mix.Tasks.Diogramos.AndroidFixtures do
365 365 }},
366 366 {"update connector invalid routing",
367 367 %{"type" => "update_connector", "id" => "k1", "patch" => %{"routing" => "zigzag"}}},
368 + # The server accepts an unrecognised anchor string -- valid_anchor?/1
369 + # falls through to the catch-all map clause. Pinned so the port
370 + # doesn't get stricter than the thing it mirrors.
371 + {"update connector unknown anchor",
372 + %{
373 + "type" => "update_connector",
374 + "id" => "k1",
375 + "patch" => %{"from" => %{"anchor" => "northwest"}}
376 + }},
377 + {"insert connector with an unknown anchor",
378 + %{
379 + "type" => "insert_connector",
380 + "connector" => %{
381 + "id" => "k5",
382 + "from" => %{"element" => "a", "anchor" => "sideways"},
383 + "to" => %{"element" => "c"}
384 + }
385 + }},
386 + {"insert connector with a point anchor missing coordinates",
387 + %{
388 + "type" => "insert_connector",
389 + "connector" => %{
390 + "id" => "k6",
391 + "from" => %{"element" => "a", "anchor" => "point"},
392 + "to" => %{"element" => "c"}
393 + }
394 + }},
368 395 {"delete connector", %{"type" => "delete_connector", "id" => "k1"}},
369 396 {"delete missing connector", %{"type" => "delete_connector", "id" => "ghost"}},
370 397
modified test/diogramos/diagrams/connector_geometry_test.exs
+42 −0
@@ -104,4 +104,46 @@ defmodule Diogramos.Diagrams.ConnectorGeometryTest do
104 104 # from = east edge of a = (100, 30); to = west edge of b = (300, 230)
105 105 assert rendered.d == "M 100 30 L 300 230"
106 106 end
107 +
108 + describe "malformed anchors" do
109 + test "a point anchor with no coordinates is skipped, not raised on" do
110 + document = doc()
111 +
112 + connector =
113 + connector(%{
114 + "from" => %{"element" => "a", "anchor" => "point"},
115 + "to" => %{"element" => "b"}
116 + })
117 +
118 + # Document.apply_op accepts this shape, so it can be stored -- and
119 + # once /api/v1 and the channel existed, stored by a client rather
120 + # than by the editor. Raising here took out every render of the
121 + # canvas, permanently, for everyone.
122 + assert ConnectorGeometry.render(connector, document) == nil
123 + end
124 +
125 + test "a point anchor with non-numeric coordinates is also skipped" do
126 + document = doc()
127 +
128 + connector =
129 + connector(%{
130 + "from" => %{"element" => "a", "anchor" => "point", "x" => "12", "y" => nil},
131 + "to" => %{"element" => "b"}
132 + })
133 +
134 + assert ConnectorGeometry.render(connector, document) == nil
135 + end
136 +
137 + test "a well-formed point anchor still renders" do
138 + document = doc()
139 +
140 + connector =
141 + connector(%{
142 + "from" => %{"element" => "a", "anchor" => "point", "x" => 5, "y" => 7},
143 + "to" => %{"element" => "b", "anchor" => "w"}
144 + })
145 +
146 + assert %{d: "M 5 7" <> _} = ConnectorGeometry.render(connector, document)
147 + end
148 + end
107 149 end

Parents: 444916c