dsh-drawai 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/lib/client.js +142 -16
- package/lib/index.js +7945 -7719
- package/lib/motion/asset-pack.js +394 -394
- package/lib/motion/assets.js +291 -291
- package/lib/motion/demo-assets-lock.js +10 -10
- package/lib/motion/mgstyles/briefs/23-ink-wash.md +147 -0
- package/lib/motion/mgstyles/catalog.d/23-ink-wash.json +28 -0
- package/lib/motion/mgstyles/catalog.json +127 -14
- package/lib/motion/mgstyles/upstream/demos/23-ink-wash/brush.js +149 -0
- package/lib/motion/mgstyles/upstream/demos/23-ink-wash/film.js +202 -0
- package/lib/motion/mgstyles/upstream/manifest.json +28 -3
- package/lib/motion/mgstyles/upstream/prompts/23-ink-wash.md +59 -0
- package/lib/motion/mgstyles/vendor/README.md +28 -28
- package/lib/motion/mgstyles/vendor/modules/d3/LICENSE +13 -13
- package/lib/motion/mgstyles/vendor/modules/d3/dist/d3.min.js +2 -2
- package/lib/motion/mgstyles/vendor/modules/opentype.js/LICENSE +20 -20
- package/lib/motion/mgstyles/vendor/modules/opentype.js/dist/opentype.mjs +14460 -14460
- package/lib/motion/mgstyles/vendor/modules/simplex-noise/LICENSE +21 -21
- package/lib/motion/mgstyles/vendor/modules/simplex-noise/dist/esm/simplex-noise.js +465 -465
- package/lib/motion/mgstyles/vendor/modules/topojson-client/LICENSE +13 -13
- package/lib/motion/mgstyles/vendor/modules/topojson-client/dist/topojson-client.min.js +2 -2
- package/lib/motion/project.js +5 -1
- package/lib/motion/qa.js +116 -6
- package/lib/motion/render.js +179 -179
- package/lib/motion/scene-kit.js +1 -1
- package/lib/motion/shotcraft/upstream/SKILL.md +286 -286
- package/lib/motion/shotcraft/upstream/assets/lib/Caption.tsx +54 -54
- package/lib/motion/shotcraft/upstream/assets/lib/ClipCard.tsx +200 -200
- package/lib/motion/shotcraft/upstream/assets/lib/DigitRoll.tsx +45 -45
- package/lib/motion/shotcraft/upstream/assets/lib/FlashCut.tsx +20 -20
- package/lib/motion/shotcraft/upstream/assets/lib/FlatPanel.tsx +65 -65
- package/lib/motion/shotcraft/upstream/assets/lib/PageCam.tsx +151 -151
- package/lib/motion/shotcraft/upstream/assets/lib/VerticalTicker.tsx +129 -129
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/camera.tsx +75 -75
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/motion.ts +40 -40
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/rand.ts +11 -11
- package/lib/motion/shotcraft/upstream/assets/lib/helpers/shake.ts +10 -10
- package/lib/motion/shotcraft/upstream/assets/scripts/build-readme-cover.mjs +49 -49
- package/lib/motion/shotcraft/upstream/assets/scripts/capture-template.mjs +210 -210
- package/lib/motion/shotcraft/upstream/assets/scripts/sfx-source.py +423 -423
- package/lib/motion/shotcraft/upstream/assets/scripts/smoke-render-demos.py +170 -170
- package/lib/motion/shotcraft/upstream/assets/scripts/test_sfx_source.py +157 -157
- package/lib/motion/shotcraft/upstream/demos/README.md +120 -120
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Brand.tsx +112 -112
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Fixtures.tsx +647 -647
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Look.tsx +316 -316
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Motion.tsx +82 -82
- package/lib/motion/shotcraft/upstream/demos/_fixtures/PageCam2D.tsx +115 -115
- package/lib/motion/shotcraft/upstream/demos/_fixtures/Polish.tsx +243 -243
- package/lib/motion/shotcraft/upstream/demos/_textures/live-layout.json +388 -388
- package/lib/motion/shotcraft/upstream/demos/camera/basic-3d-scene/Basic3DScene.tsx +250 -250
- package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashImpactReal.tsx +305 -305
- package/lib/motion/shotcraft/upstream/demos/camera/crash-zoom-punch/CrashZoomReal.tsx +246 -246
- package/lib/motion/shotcraft/upstream/demos/camera/cursor-flyover/CursorFlyover.tsx +390 -390
- package/lib/motion/shotcraft/upstream/demos/camera/depth-layer-moves/MultiplaneReal.tsx +138 -138
- package/lib/motion/shotcraft/upstream/demos/camera/graze-face-tour/GrazeFaceTour.tsx +542 -542
- package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/OverheadTabletopDrop.tsx +315 -315
- package/lib/motion/shotcraft/upstream/demos/camera/overhead-camera-moves/TiltReveal.tsx +299 -299
- package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/DroneDiveLanding.tsx +173 -173
- package/lib/motion/shotcraft/upstream/demos/camera/space-camera-moves/ExplodedView.tsx +348 -348
- package/lib/motion/shotcraft/upstream/demos/camera/steep-tilt-glide/SteepTiltGlide.tsx +366 -366
- package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/BulletTimeFreezeOrbit.tsx +279 -279
- package/lib/motion/shotcraft/upstream/demos/camera/tension-camera-moves/PullBackIsolation.tsx +184 -184
- package/lib/motion/shotcraft/upstream/demos/camera/terminal-3d/Terminal3D.tsx +390 -390
- package/lib/motion/shotcraft/upstream/demos/data/before-after-slider-scrub/BeforeAfterSliderScrub.tsx +155 -155
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/AxisRescaleShockV2.tsx +303 -303
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/OscilloscopeStreamV2.tsx +290 -290
- package/lib/motion/shotcraft/upstream/demos/data/chart-live-moves/UnitDotSwarmRegroupV2.tsx +278 -278
- package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/NeedleSweepSelftest.tsx +307 -307
- package/lib/motion/shotcraft/upstream/demos/data/gauge-readout-moves/TapeScrollFixedPointer.tsx +215 -215
- package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/BrakeReticleLock.tsx +299 -299
- package/lib/motion/shotcraft/upstream/demos/data/scroll-brake-moves/ChangelogScrollBrake.tsx +232 -232
- package/lib/motion/shotcraft/upstream/demos/data/timeline-travel/TimelineTravel.tsx +290 -290
- package/lib/motion/shotcraft/upstream/demos/effects/assemble-then-type-flyin/AssembleThenTypeFlyin.tsx +273 -273
- package/lib/motion/shotcraft/upstream/demos/effects/aurora-bloom-bg-flip/AuroraBloomBgFlip.tsx +167 -167
- package/lib/motion/shotcraft/upstream/demos/effects/brand-frame-snap/BrandFrameSnap.tsx +536 -536
- package/lib/motion/shotcraft/upstream/demos/effects/fui-hud-moves/ReticleLockOn.tsx +164 -164
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/FlylineArc.tsx +175 -175
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/GlowOrbAmbient.tsx +197 -197
- package/lib/motion/shotcraft/upstream/demos/effects/glow-flyline-moves/OrbFlylineRelay.tsx +241 -241
- package/lib/motion/shotcraft/upstream/demos/effects/icon-performance-moves/AttentionBounce.tsx +279 -279
- package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/AnimeImpact.tsx +230 -230
- package/lib/motion/shotcraft/upstream/demos/effects/impact-feedback/HitCounter.tsx +345 -345
- package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SheenSweepRetry.tsx +275 -275
- package/lib/motion/shotcraft/upstream/demos/effects/light-play-moves/SpotlightSweepReveal.tsx +229 -229
- package/lib/motion/shotcraft/upstream/demos/effects/radial-ripple-phone-chips/RadialRipplePhoneChips.tsx +312 -312
- package/lib/motion/shotcraft/upstream/demos/effects/riso-print-hits/RisoMisregistrationHit.tsx +222 -222
- package/lib/motion/shotcraft/upstream/demos/effects/scan-bracket-sweep/ScanBracketSweep.tsx +295 -295
- package/lib/motion/shotcraft/upstream/demos/effects/scanline-annotate-focus/ScanlineAnnotateFocus.tsx +329 -329
- package/lib/motion/shotcraft/upstream/demos/effects/scanline-assemble-flyin/ScanlineAssembleFlyin.tsx +315 -315
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ImpactBurstKit.tsx +264 -264
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/KanadaPerspectiveSnap.tsx +260 -260
- package/lib/motion/shotcraft/upstream/demos/effects/slam-entrance-moves/ScoreSlam.tsx +263 -263
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/CornerSpotlightReveal.tsx +123 -123
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/GlowWakeSleepPanel.tsx +187 -187
- package/lib/motion/shotcraft/upstream/demos/effects/spotlight-sweep-moves/SlideSpotlightPan.tsx +175 -175
- package/lib/motion/shotcraft/upstream/demos/interaction/autolayout-gap-dial/AutolayoutGapDial.tsx +305 -305
- package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx +350 -350
- package/lib/motion/shotcraft/upstream/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx +352 -352
- package/lib/motion/shotcraft/upstream/demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.tsx +218 -218
- package/lib/motion/shotcraft/upstream/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx +297 -297
- package/lib/motion/shotcraft/upstream/demos/interaction/glass-pill-dictation-typing/GlassPillDictationTyping.tsx +157 -157
- package/lib/motion/shotcraft/upstream/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx +218 -218
- package/lib/motion/shotcraft/upstream/demos/interaction/picker-carousel-feature-cycle/PickerCarouselFeatureCycle.tsx +202 -202
- package/lib/motion/shotcraft/upstream/demos/interaction/segmented-thumb-hero/SegmentedThumbHero.tsx +265 -265
- package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/PaletteThemeRipple.tsx +457 -457
- package/lib/motion/shotcraft/upstream/demos/interaction/theme-switch-moves/ThemeSweepToggle.tsx +446 -446
- package/lib/motion/shotcraft/upstream/demos/interaction/type-and-filter/TypeAndFilter.tsx +249 -249
- package/lib/motion/shotcraft/upstream/demos/interaction/voice-waveform-live/VoiceWaveformLive.tsx +278 -278
- package/lib/motion/shotcraft/upstream/demos/opening/crane-rise-reveal/CraneRiseReveal.tsx +332 -332
- package/lib/motion/shotcraft/upstream/demos/opening/dataviz-landscape-open/DatavizLandscapeOpen.tsx +358 -358
- package/lib/motion/shotcraft/upstream/demos/opening/fracture/Fracture.tsx +201 -201
- package/lib/motion/shotcraft/upstream/demos/opening/icon-field-colorize/IconFieldColorize.tsx +200 -200
- package/lib/motion/shotcraft/upstream/demos/opening/letterspace-materialize/LetterspaceMaterialize.tsx +208 -208
- package/lib/motion/shotcraft/upstream/demos/opening/magician-card-flourish/MagicianCardFlourish.tsx +341 -341
- package/lib/motion/shotcraft/upstream/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx +389 -389
- package/lib/motion/shotcraft/upstream/demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx +293 -293
- package/lib/motion/shotcraft/upstream/demos/opening/stroke-segment-build/StrokeSegmentBuild.tsx +183 -183
- package/lib/motion/shotcraft/upstream/demos/opening/text-as-mask/TextAsMask.tsx +245 -245
- package/lib/motion/shotcraft/upstream/demos/outro/grain-dissolve/GrainDissolve.tsx +222 -222
- package/lib/motion/shotcraft/upstream/demos/outro/logo-shrink-wordmark-lockup/LogoShrinkWordmarkLockup.tsx +223 -223
- package/lib/motion/shotcraft/upstream/demos/outro/neon-triple-marquee/NeonTripleMarquee.tsx +217 -217
- package/lib/motion/shotcraft/upstream/demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx +343 -343
- package/lib/motion/shotcraft/upstream/demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx +357 -357
- package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/IconFlipBloomLogo.tsx +187 -187
- package/lib/motion/shotcraft/upstream/demos/outro/ui-to-brand-morph/InputMorphsIntoLogo.tsx +333 -333
- package/lib/motion/shotcraft/upstream/demos/rhythm/beat-cut-moves/PaparazziFlash.tsx +274 -274
- package/lib/motion/shotcraft/upstream/demos/rhythm/beat-step-list-theme-cycle/BeatStepListThemeCycle.tsx +270 -270
- package/lib/motion/shotcraft/upstream/demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx +430 -430
- package/lib/motion/shotcraft/upstream/demos/rhythm/quad-split-parallel-scenes/QuadSplitParallelScenes.tsx +252 -252
- package/lib/motion/shotcraft/upstream/demos/rhythm/rhythm-interrupt-moves/JumpCutPunchIn.tsx +106 -106
- package/lib/motion/shotcraft/upstream/demos/rhythm/sakuga-timing-shift/SakugaTimingShift.tsx +288 -288
- package/lib/motion/shotcraft/upstream/demos/rhythm/speed-ramp-freeze/FreezeAnnotateReal.tsx +362 -362
- package/lib/motion/shotcraft/upstream/demos/rhythm/trailer-grammar-moves/TrailerBumper.tsx +168 -168
- package/lib/motion/shotcraft/upstream/demos/transition/bottom-push-stack-wipe/BottomPushStackWipe.tsx +310 -310
- package/lib/motion/shotcraft/upstream/demos/transition/card-flip-reveal/CardFlipReveal.tsx +261 -261
- package/lib/motion/shotcraft/upstream/demos/transition/card-flock-tumble/CardFlockTumble.tsx +459 -459
- package/lib/motion/shotcraft/upstream/demos/transition/circle-match-iris/CircleMatchIris.tsx +294 -294
- package/lib/motion/shotcraft/upstream/demos/transition/gradient-transition/GradientTransition.tsx +161 -161
- package/lib/motion/shotcraft/upstream/demos/transition/line-carry-transition/LineCarryTransition.tsx +248 -248
- package/lib/motion/shotcraft/upstream/demos/transition/mosaic-reframe/MosaicReframe.tsx +359 -359
- package/lib/motion/shotcraft/upstream/demos/transition/page-turn-transitions/BarnDoorSplit.tsx +227 -227
- package/lib/motion/shotcraft/upstream/demos/transition/print-texture-transitions/InkBleedReveal.tsx +162 -162
- package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/PortalWipeV2.tsx +248 -248
- package/lib/motion/shotcraft/upstream/demos/transition/shot-transitions/WhipBrakeReal.tsx +186 -186
- package/lib/motion/shotcraft/upstream/demos/transition/tear-streak-transitions/GlitchDisplace.tsx +355 -355
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/InvisibleCut.tsx +272 -272
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/LightLeakBurn.tsx +262 -262
- package/lib/motion/shotcraft/upstream/demos/transition/transition-hidden-cut/VersusSlam.tsx +217 -217
- package/lib/motion/shotcraft/upstream/demos/transition/transition-travel/LetterformZoom.tsx +285 -285
- package/lib/motion/shotcraft/upstream/demos/transition/white-flash-logo-simplify-cut/WhiteFlashLogoSimplifyCut.tsx +205 -205
- package/lib/motion/shotcraft/upstream/demos/transition/wipe-transitions/BlindsSlice.tsx +178 -178
- package/lib/motion/shotcraft/upstream/demos/typography/blur-slide/BlurSlide.tsx +147 -147
- package/lib/motion/shotcraft/upstream/demos/typography/brace-expand/BraceExpand.tsx +143 -143
- package/lib/motion/shotcraft/upstream/demos/typography/brand-ink-open/BrandInkOpen.tsx +134 -134
- package/lib/motion/shotcraft/upstream/demos/typography/countdown-arc-scatter/CountdownArcScatter.tsx +217 -217
- package/lib/motion/shotcraft/upstream/demos/typography/flying-words/FlyingWords.tsx +198 -198
- package/lib/motion/shotcraft/upstream/demos/typography/glitch-cycle/GlitchCycle.tsx +221 -221
- package/lib/motion/shotcraft/upstream/demos/typography/gradient-word-sweep/GradientWordSweep.tsx +312 -312
- package/lib/motion/shotcraft/upstream/demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx +261 -261
- package/lib/motion/shotcraft/upstream/demos/typography/marker-underline-title/MarkerUnderlineTitle.tsx +121 -121
- package/lib/motion/shotcraft/upstream/demos/typography/outline-word-fill/OutlineWordFill.tsx +153 -153
- package/lib/motion/shotcraft/upstream/demos/typography/paper-title-card/PaperTitleCard.tsx +179 -179
- package/lib/motion/shotcraft/upstream/demos/typography/pill-chip-slot-cycle-handled/PillChipSlotCycleHandled.tsx +140 -140
- package/lib/motion/shotcraft/upstream/demos/typography/pill-slot-cycle/PillSlotCycle.tsx +309 -309
- package/lib/motion/shotcraft/upstream/demos/typography/text-column-converge/TextColumnConverge.tsx +191 -191
- package/lib/motion/shotcraft/upstream/demos/typography/title-demote-to-label/TitleDemoteToLabel.tsx +292 -292
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/LetterformDriftAssembly.tsx +135 -135
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/SplitTextStagger.tsx +124 -124
- package/lib/motion/shotcraft/upstream/demos/typography/type-assembly-moves/TextOnPath.tsx +255 -255
- package/lib/motion/shotcraft/upstream/demos/typography/type-entrance-moves/LetterDropPhysics.tsx +173 -173
- package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/FontWeightPump.tsx +225 -225
- package/lib/motion/shotcraft/upstream/demos/typography/type-rhythm-sync/KaraokeFillSync.tsx +141 -141
- package/lib/motion/shotcraft/upstream/demos/typography/typewriter-moves/TypewriterErrorRetype.tsx +137 -137
- package/lib/motion/shotcraft/upstream/demos/typography/vertical-word-roll-blur-cycle/VerticalWordRollBlurCycle.tsx +147 -147
- package/lib/motion/shotcraft/upstream/demos/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx +409 -409
- package/lib/motion/shotcraft/upstream/demos/typography/word-relay-geometry/WordRelayGeometry.tsx +280 -280
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/avatar-bracket-carousel/AvatarBracketCarousel.tsx +271 -271
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/bezier-source-converge-merge/BezierSourceConvergeMerge.tsx +385 -385
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/card-stack/CardStack.tsx +312 -312
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/carousel-3d/Carousel3D.tsx +303 -303
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/clipcard-looping/ClipCardLooping.tsx +77 -77
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/cloner-depth-echo/ClonerDepthEcho.tsx +311 -311
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/deck-deal-flyin/DeckDealFlyin.tsx +307 -307
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/doc-park-left-pill-deal/DocParkLeftPillDeal.tsx +349 -349
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/document-typewriter-reveal/DocumentTypewriterReveal.tsx +255 -255
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/draw-svg-trace/DrawSvgTrace.tsx +244 -244
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/element-body-moves/ContactShadowLift.tsx +217 -217
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/integration-hub-map/IntegrationHubMap.tsx +652 -652
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-reveal/ListReveal.tsx +153 -153
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/list-stack-press/ListStackPress.tsx +238 -238
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-forerun/NeonFrameForerun.tsx +324 -324
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/neon-frame-orbit-drop/NeonFrameForerunOrbit.tsx +354 -354
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/PageWaterfallWall.tsx +101 -101
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/page-waterfall-wall/VerticalTicker.tsx +129 -129
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx +297 -297
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/radial-wave/RadialWave.tsx +57 -57
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/research-card-stack-scroll/ResearchCardStackScroll.tsx +155 -155
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/row-embed/RowEmbed.tsx +175 -175
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/value-stagger-gradient/ValueStaggerGradient.tsx +153 -153
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/BentoLightUp.tsx +146 -146
- package/lib/motion/shotcraft/upstream/demos/ui-entrance/wall-reveal-moves/WireframeDrawOn.tsx +226 -226
- package/lib/motion/shotcraft/upstream/gallery/api/library.json +1 -1
- package/lib/motion/shotcraft/upstream/references/aesthetic-rules.md +193 -193
- package/lib/motion/shotcraft/upstream/references/final-review.md +121 -121
- package/lib/motion/shotcraft/upstream/references/guided-free-creation.md +199 -199
- package/lib/motion/shotcraft/upstream/references/jianying-export.md +183 -183
- package/lib/motion/shotcraft/upstream/references/music-beat-sync.md +257 -257
- package/lib/motion/shotcraft/upstream/references/pipeline.md +382 -382
- package/lib/motion/shotcraft/upstream/references/publish-upload-post.md +111 -111
- package/lib/motion/shotcraft/upstream/references/sequences/promo-energy-arc.md +50 -50
- package/lib/motion/shotcraft/upstream/references/sfx-sourcing.md +95 -95
- package/lib/motion/shotcraft/upstream/references/shots/camera/basic-3d-scene.md +41 -41
- package/lib/motion/shotcraft/upstream/references/shots/camera/crash-zoom-punch.md +52 -52
- package/lib/motion/shotcraft/upstream/references/shots/camera/cursor-flyover.md +39 -39
- package/lib/motion/shotcraft/upstream/references/shots/camera/depth-layer-moves.md +39 -39
- package/lib/motion/shotcraft/upstream/references/shots/camera/graze-face-tour.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/camera/overhead-camera-moves.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/camera/space-camera-moves.md +46 -46
- package/lib/motion/shotcraft/upstream/references/shots/camera/steep-tilt-glide.md +52 -52
- package/lib/motion/shotcraft/upstream/references/shots/camera/tension-camera-moves.md +43 -43
- package/lib/motion/shotcraft/upstream/references/shots/camera/terminal-3d.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/data/before-after-slider-scrub.md +48 -48
- package/lib/motion/shotcraft/upstream/references/shots/data/chart-live-moves.md +52 -52
- package/lib/motion/shotcraft/upstream/references/shots/data/gauge-readout-moves.md +45 -45
- package/lib/motion/shotcraft/upstream/references/shots/data/scroll-brake-moves.md +46 -46
- package/lib/motion/shotcraft/upstream/references/shots/data/timeline-travel.md +44 -44
- package/lib/motion/shotcraft/upstream/references/shots/effects/assemble-then-type-flyin.md +63 -63
- package/lib/motion/shotcraft/upstream/references/shots/effects/aurora-bloom-bg-flip.md +54 -54
- package/lib/motion/shotcraft/upstream/references/shots/effects/brand-frame-snap.md +58 -58
- package/lib/motion/shotcraft/upstream/references/shots/effects/fui-hud-moves.md +35 -35
- package/lib/motion/shotcraft/upstream/references/shots/effects/glow-flyline-moves.md +53 -53
- package/lib/motion/shotcraft/upstream/references/shots/effects/icon-performance-moves.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/effects/impact-feedback.md +50 -50
- package/lib/motion/shotcraft/upstream/references/shots/effects/light-play-moves.md +46 -46
- package/lib/motion/shotcraft/upstream/references/shots/effects/radial-ripple-phone-chips.md +56 -56
- package/lib/motion/shotcraft/upstream/references/shots/effects/riso-print-hits.md +49 -49
- package/lib/motion/shotcraft/upstream/references/shots/effects/scan-bracket-sweep.md +50 -50
- package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-annotate-focus.md +53 -53
- package/lib/motion/shotcraft/upstream/references/shots/effects/scanline-assemble-flyin.md +53 -53
- package/lib/motion/shotcraft/upstream/references/shots/effects/slam-entrance-moves.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/effects/spotlight-sweep-moves.md +52 -52
- package/lib/motion/shotcraft/upstream/references/shots/interaction/autolayout-gap-dial.md +60 -60
- package/lib/motion/shotcraft/upstream/references/shots/interaction/canvas-materialize-moves.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/interaction/chip-grid-single-select-blackout.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/interaction/collab-cursor-moves.md +41 -41
- package/lib/motion/shotcraft/upstream/references/shots/interaction/glass-pill-dictation-typing.md +53 -53
- package/lib/motion/shotcraft/upstream/references/shots/interaction/hashtag-to-pill-materialize.md +60 -60
- package/lib/motion/shotcraft/upstream/references/shots/interaction/picker-carousel-feature-cycle.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/interaction/segmented-thumb-hero.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/interaction/theme-switch-moves.md +47 -47
- package/lib/motion/shotcraft/upstream/references/shots/interaction/type-and-filter.md +37 -37
- package/lib/motion/shotcraft/upstream/references/shots/interaction/voice-waveform-live.md +54 -54
- package/lib/motion/shotcraft/upstream/references/shots/opening/brand-ink-open.md +36 -36
- package/lib/motion/shotcraft/upstream/references/shots/opening/crane-rise-reveal.md +56 -56
- package/lib/motion/shotcraft/upstream/references/shots/opening/dataviz-landscape-open.md +56 -56
- package/lib/motion/shotcraft/upstream/references/shots/opening/fracture.md +40 -40
- package/lib/motion/shotcraft/upstream/references/shots/opening/icon-field-colorize.md +60 -60
- package/lib/motion/shotcraft/upstream/references/shots/opening/letterspace-materialize.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/opening/magician-card-flourish.md +73 -73
- package/lib/motion/shotcraft/upstream/references/shots/opening/orbit-ring-title-open.md +87 -87
- package/lib/motion/shotcraft/upstream/references/shots/opening/spotlight-hero-card.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/opening/stroke-segment-build.md +50 -50
- package/lib/motion/shotcraft/upstream/references/shots/opening/text-as-mask.md +59 -59
- package/lib/motion/shotcraft/upstream/references/shots/outro/grain-dissolve.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/outro/logo-shrink-wordmark-lockup.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/outro/neon-triple-marquee.md +59 -59
- package/lib/motion/shotcraft/upstream/references/shots/outro/outro-group-photo-launch.md +39 -39
- package/lib/motion/shotcraft/upstream/references/shots/outro/ui-strip-away-outro.md +61 -61
- package/lib/motion/shotcraft/upstream/references/shots/outro/ui-to-brand-morph.md +52 -52
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-cut-moves.md +50 -50
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/beat-step-list-theme-cycle.md +66 -66
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/panel-grid-moves.md +43 -43
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/quad-split-parallel-scenes.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/rhythm-interrupt-moves.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/sakuga-timing-shift.md +56 -56
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/speed-ramp-freeze.md +44 -44
- package/lib/motion/shotcraft/upstream/references/shots/rhythm/trailer-grammar-moves.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/transition/bottom-push-stack-wipe.md +63 -63
- package/lib/motion/shotcraft/upstream/references/shots/transition/card-flip-reveal.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/transition/card-flock-tumble.md +63 -63
- package/lib/motion/shotcraft/upstream/references/shots/transition/circle-match-iris.md +58 -58
- package/lib/motion/shotcraft/upstream/references/shots/transition/gradient-transition.md +45 -45
- package/lib/motion/shotcraft/upstream/references/shots/transition/line-carry-transition.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/transition/mosaic-reframe.md +49 -49
- package/lib/motion/shotcraft/upstream/references/shots/transition/page-turn-transitions.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/transition/print-texture-transitions.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/transition/shot-transitions.md +45 -45
- package/lib/motion/shotcraft/upstream/references/shots/transition/tear-streak-transitions.md +48 -48
- package/lib/motion/shotcraft/upstream/references/shots/transition/transition-hidden-cut.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/transition/transition-travel.md +43 -43
- package/lib/motion/shotcraft/upstream/references/shots/transition/white-flash-logo-simplify-cut.md +41 -41
- package/lib/motion/shotcraft/upstream/references/shots/transition/wipe-transitions.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/typography/blur-slide.md +59 -59
- package/lib/motion/shotcraft/upstream/references/shots/typography/brace-expand.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/typography/countdown-arc-scatter.md +65 -65
- package/lib/motion/shotcraft/upstream/references/shots/typography/document-typewriter-reveal.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/typography/flying-words.md +64 -64
- package/lib/motion/shotcraft/upstream/references/shots/typography/glitch-cycle.md +64 -64
- package/lib/motion/shotcraft/upstream/references/shots/typography/gradient-word-sweep.md +54 -54
- package/lib/motion/shotcraft/upstream/references/shots/typography/lead-word-zoom-assemble.md +91 -91
- package/lib/motion/shotcraft/upstream/references/shots/typography/marker-underline-title.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/typography/outline-word-fill.md +66 -66
- package/lib/motion/shotcraft/upstream/references/shots/typography/paper-title-card.md +38 -38
- package/lib/motion/shotcraft/upstream/references/shots/typography/pill-chip-slot-cycle-handled.md +66 -66
- package/lib/motion/shotcraft/upstream/references/shots/typography/pill-slot-cycle.md +58 -58
- package/lib/motion/shotcraft/upstream/references/shots/typography/text-column-converge.md +61 -61
- package/lib/motion/shotcraft/upstream/references/shots/typography/title-demote-to-label.md +64 -64
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-assembly-moves.md +45 -45
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-entrance-moves.md +41 -41
- package/lib/motion/shotcraft/upstream/references/shots/typography/type-rhythm-sync.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/typography/typewriter-moves.md +42 -42
- package/lib/motion/shotcraft/upstream/references/shots/typography/vertical-word-roll-blur-cycle.md +63 -63
- package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-filmstrip.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/typography/word-relay-geometry.md +75 -75
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/avatar-bracket-carousel.md +59 -59
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/bezier-source-converge-merge.md +67 -67
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/card-stack.md +60 -60
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/carousel-3d.md +66 -66
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/cloner-depth-echo.md +40 -40
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/deck-deal-flyin.md +45 -45
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/doc-park-left-pill-deal.md +62 -62
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/draw-svg-trace.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/element-body-moves.md +37 -37
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/integration-hub-map.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-reveal.md +50 -50
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/list-stack-press.md +43 -43
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-forerun.md +55 -55
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/neon-frame-orbit-drop.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/page-waterfall-wall.md +51 -51
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/product-card-progressive-assemble.md +56 -56
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/radial-wave.md +49 -49
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/research-card-stack-scroll.md +57 -57
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/row-embed.md +36 -36
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/value-stagger-gradient.md +64 -64
- package/lib/motion/shotcraft/upstream/references/shots/ui-entrance/wall-reveal-moves.md +47 -47
- package/lib/motion/shotcraft/upstream/references/sound-design.md +170 -170
- package/lib/motion/shotcraft/upstream/references/workbench.md +157 -157
- package/lib/motion/shotcraft/upstream/template/TEMPLATE.md +103 -103
- package/lib/motion/shotcraft/upstream/template/THEMES.md +44 -44
- package/lib/motion/shotcraft/upstream/template/package.json +20 -20
- package/lib/motion/shotcraft/upstream/template/remotion.config.ts +6 -6
- package/lib/motion/shotcraft/upstream/template/src/Root.tsx +17 -17
- package/lib/motion/shotcraft/upstream/template/src/aifl/Caption.tsx +65 -65
- package/lib/motion/shotcraft/upstream/template/src/aifl/DigitRoll.tsx +48 -48
- package/lib/motion/shotcraft/upstream/template/src/aifl/FlashCut.tsx +24 -24
- package/lib/motion/shotcraft/upstream/template/src/aifl/Main.tsx +192 -192
- package/lib/motion/shotcraft/upstream/template/src/aifl/PaperTitleCard.tsx +111 -111
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/PageCam.tsx +150 -150
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneDetail.tsx +158 -158
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneFlyIn.tsx +468 -468
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOpen.tsx +552 -552
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneOutroLive.tsx +411 -411
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/ScenePapers.tsx +200 -200
- package/lib/motion/shotcraft/upstream/template/src/aifl/live/SceneWbr.tsx +324 -324
- package/lib/motion/shotcraft/upstream/template/src/aifl/live-layout.json +388 -388
- package/lib/motion/shotcraft/upstream/template/src/index.ts +4 -4
- package/lib/motion/shotcraft/upstream/template/src/themes/ThemedFilm.tsx +7 -7
- package/lib/motion/shotcraft/upstream/template/src/themes/manifest.tsx +68 -68
- package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.json +1 -1
- package/lib/motion/shotcraft/upstream/template/src/themes/palette-assets.ts +24 -24
- package/lib/motion/shotcraft/upstream/template/src/themes/palettes.json +74 -74
- package/lib/motion/shotcraft/upstream/template/src/themes/visual-theme.tsx +91 -91
- package/lib/motion/shotcraft/upstream/template/src/workbench.ts +213 -213
- package/lib/motion/shotcraft/upstream/template/tsconfig.json +15 -15
- package/lib/motion/style-frames.js +306 -306
- package/lib/motion/sync.js +214 -214
- package/lib/motion-library.js +1 -1
- package/lib/motion-project.js +105 -105
- package/lib/motion-render-worker.js +174 -174
- package/lib/routing/router.js +222 -222
- package/lib/routing/vendor/README.md +96 -96
- package/lib/routing/vendor/libavoid-routing.js +753 -753
- package/lib/routing/vendor/libavoid.min.js +59 -59
- package/lib/svg-export.js +1344 -0
- package/lib/video-trace-pool.js +128 -128
- package/package.json +3 -2
- package/LICENSE +0 -4175
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
/* DrawAI 宿主侧导出:把文档对象直接画成 SVG(零依赖),PNG 再交给 @resvg/resvg-js。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这条路径(issue #23):导出一直挂在浏览器那一半 ——
|
|
5
|
+
* `diagram_read {render:true}` 与 `diagram_apply {op:"export"}` 都要用户在右侧栏
|
|
6
|
+
* 打开着画布才有人接。QQ / CLI 这类没有侧栏的通道里,宿主能读写 .drawai,
|
|
7
|
+
* 成品图却拿不到,而那时成品图正是唯一的交付物。
|
|
8
|
+
*
|
|
9
|
+
* 这一份是**按同一套规则独立重画**,不是把客户端的渲染器搬过来:客户端是 React
|
|
10
|
+
* 组件树 + 浏览器字体度量 + 画布缩放,宿主两样都没有。所以:
|
|
11
|
+
* · 几何(形状、连线、标签位置、换行、导出包围盒)照抄客户端的纯函数口径;
|
|
12
|
+
* · 文字量宽用 `charWidth`(CJK 按 1em、其余按 0.55em 估算)—— 与客户端
|
|
13
|
+
* **无 DOM 时的回退**同一套,常见标签断行一致,长标签可能差一行;
|
|
14
|
+
* · 视频/卡片/音频这类由客户端组件画的节点只画出通用形状,不还原内部画面。
|
|
15
|
+
* 因此调用方要把保真度标成 `approximate`,并把 warnings 一并交出去。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_FONT,
|
|
20
|
+
arrowFromStyle,
|
|
21
|
+
curvedFromStyle,
|
|
22
|
+
dashPatternFromStyle,
|
|
23
|
+
imageValueFromStyle,
|
|
24
|
+
isOrthogonalEdgeStyle,
|
|
25
|
+
nodeShapeFromStyle,
|
|
26
|
+
sideFromStyle,
|
|
27
|
+
styleGet,
|
|
28
|
+
styleNumber,
|
|
29
|
+
} from './style-kernel.js'
|
|
30
|
+
import { createOrthogonalRouter, storedRoute } from './routing/router.js'
|
|
31
|
+
import { getRoutingRuntime } from './routing/node-runtime.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 没有折点的正交边,用**宿主这一份**路由器补一条真走线。
|
|
35
|
+
*
|
|
36
|
+
* 为什么不干脆按直线画:`edgeStyle=orthogonalEdgeStyle` 的边在画布上从来不是斜线 ——
|
|
37
|
+
* 客户端 `routeEdgeStyled` 会跑一遍自动路由。导出若画成对角线,同一份文档在两个地方
|
|
38
|
+
* 长得不一样,而这正是 issue #23 抱怨的事("拿不到成品图"之后紧接着就是"拿到的图不对")。
|
|
39
|
+
* 引擎是懒加载的 vendor 文件,缺了/坏了就退回 Z 形近似并在 warnings 里说明。
|
|
40
|
+
*/
|
|
41
|
+
const exportRouter = createOrthogonalRouter(getRoutingRuntime)
|
|
42
|
+
|
|
43
|
+
function solveMissingRoutes(edges, boxes, warnings) {
|
|
44
|
+
const need = []
|
|
45
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
46
|
+
const edge = edges[i]
|
|
47
|
+
if (edge === null || typeof edge !== 'object') continue
|
|
48
|
+
if (Array.isArray(edge.points) && edge.points.length > 0) continue
|
|
49
|
+
const style = styleOf(edge)
|
|
50
|
+
if (isOrthogonalEdgeStyle(style) !== true) continue
|
|
51
|
+
if (curvedFromStyle(style) === true) continue
|
|
52
|
+
const fromKey = edge.from === undefined || edge.from === null ? '' : String(edge.from)
|
|
53
|
+
const toKey = edge.to === undefined || edge.to === null ? '' : String(edge.to)
|
|
54
|
+
// 自环(两端同一个节点)由 routeForEdge 单独画:交给避障求解只会得到"两端同一点"。
|
|
55
|
+
if (fromKey.length > 0 && fromKey === toKey) continue
|
|
56
|
+
need.push(Object.assign({}, edge, { from: fromKey.length > 0 ? fromKey : edge.from, to: toKey.length > 0 ? toKey : edge.to }))
|
|
57
|
+
}
|
|
58
|
+
if (need.length === 0) return new Map()
|
|
59
|
+
try {
|
|
60
|
+
const routes = exportRouter.solve(need, boxes)
|
|
61
|
+
const out = new Map()
|
|
62
|
+
for (const id of Object.keys(routes)) {
|
|
63
|
+
const points = routes[id]
|
|
64
|
+
if (Array.isArray(points) && points.length > 0) out.set(id, points)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
} catch (error) {
|
|
68
|
+
warnings.push(
|
|
69
|
+
'自动避障引擎不可用(' + (error instanceof Error ? error.message : String(error)) + '),没有折点的正交连线按 Z 形近似',
|
|
70
|
+
)
|
|
71
|
+
return new Map()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 与客户端同一套数字(src/client.js:407-461)。改这里就要同步那边,反之亦然。 */
|
|
76
|
+
const GRID = 10
|
|
77
|
+
const FSIZE = 12
|
|
78
|
+
const LHEIGHT = 14
|
|
79
|
+
const FALLBACK_NODE_W = 150
|
|
80
|
+
const FALLBACK_NODE_H = 60
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 导出件里写的 font-family:客户端的 `Helvetica, Arial` 后面补一串常见中文字体。
|
|
84
|
+
*
|
|
85
|
+
* 为什么:画布在浏览器里显示中文靠的是**系统字体回退**(`Helvetica` 里没有汉字,
|
|
86
|
+
* 浏览器按 lang 自己挑一个中文字体)。导出的 SVG 换到没有这一层回退的渲染器里
|
|
87
|
+
* (resvg、Inkscape、某些 CI 的 headless),同一串 `Helvetica, Arial, sans-serif`
|
|
88
|
+
* 就只剩方框。把常见中文系统字体显式列出来,交付物到哪都不至于变豆腐块。
|
|
89
|
+
*
|
|
90
|
+
* 顺序要紧:具名字体必须在 `sans-serif` **之前** —— 通用族一旦匹配上就不会再往后找。
|
|
91
|
+
*
|
|
92
|
+
* 量宽不读这个常量:`textWidth` 是逐字估算(CJK 1em、其余 0.55em,见 `charWidth`),
|
|
93
|
+
* 与渲染时实际命中哪个字体无关,所以换字体不会让断行位置漂移。
|
|
94
|
+
*/
|
|
95
|
+
const SVG_FONT = 'Helvetica, Arial, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif'
|
|
96
|
+
const ON_DARK_FONT = '#e8e8e8'
|
|
97
|
+
|
|
98
|
+
/** 浅色:导出交付物的缺省外观(白纸黑线),与画布的白底主题一致。 */
|
|
99
|
+
const LIGHT = {
|
|
100
|
+
page: '#ffffff',
|
|
101
|
+
text: '#000000',
|
|
102
|
+
line: '#000000',
|
|
103
|
+
labelBg: '#ffffff',
|
|
104
|
+
default: { fill: '#ffffff', stroke: '#000000' },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 深色:透明纸 + 浅色字线,贴在深色文档/幻灯片里不突兀。 */
|
|
108
|
+
const DARK = {
|
|
109
|
+
page: 'transparent',
|
|
110
|
+
text: '#e8e8e8',
|
|
111
|
+
line: '#c9c9c9',
|
|
112
|
+
labelBg: '#1c1c1c',
|
|
113
|
+
// 与客户端 src/client.js:462 一致:没写 fillColor 的节点在深色主题下是深灰底浅边,
|
|
114
|
+
// 不是白底 —— 白块贴在深色文档里会像漏画了一层。
|
|
115
|
+
default: { fill: '#2a2a2a', stroke: '#c9c9c9' },
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 导出包围盒四周留白(与客户端 exportGeometry 的缺省一致)。 */
|
|
119
|
+
const DEFAULT_PADDING = 24
|
|
120
|
+
/** 栅格化的硬上限:单边与总像素。超了宁可报错,也不要让 resvg 吃掉几个 GB。 */
|
|
121
|
+
const MAX_SIDE = 16384
|
|
122
|
+
const MAX_PIXELS = 32000000
|
|
123
|
+
|
|
124
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback)
|
|
127
|
+
const snapTo = (value, grid) => Math.round(value / grid) * grid
|
|
128
|
+
const fmt = (value) => {
|
|
129
|
+
const n = Math.round(numberOr(value, 0) * 100) / 100
|
|
130
|
+
return String(n)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapeXml(text) {
|
|
134
|
+
return String(text)
|
|
135
|
+
.replace(/&/g, '&')
|
|
136
|
+
.replace(/</g, '<')
|
|
137
|
+
.replace(/>/g, '>')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeAttr(text) {
|
|
141
|
+
return escapeXml(text).replace(/"/g, '"').replace(/'/g, ''')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 拼属性:值为 null/undefined/'' 的键直接不写(缺省即不画,与客户端一致)。 */
|
|
145
|
+
function attrs(map) {
|
|
146
|
+
let out = ''
|
|
147
|
+
for (const key of Object.keys(map)) {
|
|
148
|
+
const value = map[key]
|
|
149
|
+
if (value === null || value === undefined || value === '') continue
|
|
150
|
+
out += ' ' + key + '="' + escapeAttr(String(value)) + '"'
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 文字度量与断行(src/client.js:700-754 的宿主版)──────────────────────
|
|
156
|
+
|
|
157
|
+
function charWidth(ch, size) {
|
|
158
|
+
const code = ch.codePointAt(0)
|
|
159
|
+
const wide =
|
|
160
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
161
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
162
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
163
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
164
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
165
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
166
|
+
(code >= 0xffe0 && code <= 0xffe6)
|
|
167
|
+
return wide ? size : size * 0.55
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function textWidth(text, size) {
|
|
171
|
+
const chars = Array.from(String(text))
|
|
172
|
+
let total = 0
|
|
173
|
+
for (let i = 0; i < chars.length; i += 1) total += charWidth(chars[i], size)
|
|
174
|
+
return total
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function wrapLabel(label, maxWidth, size) {
|
|
178
|
+
const fs = numberOr(size, FSIZE)
|
|
179
|
+
const lines = []
|
|
180
|
+
const paras = String(label).split('\n')
|
|
181
|
+
for (let p = 0; p < paras.length; p += 1) {
|
|
182
|
+
const chars = Array.from(paras[p])
|
|
183
|
+
let current = ''
|
|
184
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
185
|
+
const nextWidth = textWidth(current + chars[i], fs)
|
|
186
|
+
if (nextWidth > maxWidth && current.length > 0) {
|
|
187
|
+
lines.push(current)
|
|
188
|
+
current = ''
|
|
189
|
+
}
|
|
190
|
+
current += chars[i]
|
|
191
|
+
}
|
|
192
|
+
lines.push(current)
|
|
193
|
+
}
|
|
194
|
+
return lines.length > 0 ? lines : ['']
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function labelLineHeight(fontSize) {
|
|
198
|
+
return LHEIGHT * (numberOr(fontSize, FSIZE) / FSIZE)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 配色(src/client.js:765-813)─────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function parseHexColor(value) {
|
|
204
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
205
|
+
const short = /^#([0-9a-fA-F]{3})$/.exec(text)
|
|
206
|
+
if (short !== null) {
|
|
207
|
+
const h = short[1]
|
|
208
|
+
return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16) }
|
|
209
|
+
}
|
|
210
|
+
const full = /^#([0-9a-fA-F]{6})$/.exec(text)
|
|
211
|
+
if (full !== null) {
|
|
212
|
+
const h = full[1]
|
|
213
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }
|
|
214
|
+
}
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 缺省字色只看节点自己的填充:浅底黑字、深底浅字(换主题不改文档里的字色)。 */
|
|
219
|
+
function defaultFontOnFill(fill) {
|
|
220
|
+
const rgb = parseHexColor(fill)
|
|
221
|
+
if (rgb === null) return DEFAULT_FONT
|
|
222
|
+
const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
|
|
223
|
+
return luma > 0.55 ? DEFAULT_FONT : ON_DARK_FONT
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `#rrggbbaa` 拆成颜色 + 透明度(SVG 的 fill 不认 8 位十六进制)。 */
|
|
227
|
+
function parseCssColor(value, fallback) {
|
|
228
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
229
|
+
const eight = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})$/.exec(text)
|
|
230
|
+
if (eight !== null) return { color: '#' + eight[1], opacity: Math.round((parseInt(eight[2], 16) / 255) * 1000) / 1000 }
|
|
231
|
+
return { color: text.length > 0 ? text : fallback, opacity: 1 }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function styleOf(node) {
|
|
235
|
+
return typeof node.style === 'string' ? node.style : ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function shapeOf(node) {
|
|
239
|
+
return nodeShapeFromStyle(styleOf(node))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function colorOf(node, palette) {
|
|
243
|
+
const style = styleOf(node)
|
|
244
|
+
const fill = styleGet(style, 'fillColor', null)
|
|
245
|
+
const stroke = styleGet(style, 'strokeColor', null)
|
|
246
|
+
const font = styleGet(style, 'fontColor', null)
|
|
247
|
+
return {
|
|
248
|
+
fill: fill !== null ? fill : palette.default.fill,
|
|
249
|
+
stroke: stroke !== null ? stroke : palette.default.stroke,
|
|
250
|
+
font: font !== null ? font : defaultFontOnFill(fill !== null ? fill : palette.default.fill),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 标签排版(src/client.js:1031-1307)───────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function labelWraps(node) {
|
|
257
|
+
return styleGet(styleOf(node), 'whiteSpace', 'wrap') !== 'nowrap'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function labelInsetsOf(node) {
|
|
261
|
+
const style = styleOf(node)
|
|
262
|
+
const shape = shapeOf(node)
|
|
263
|
+
const section = shape === 'section'
|
|
264
|
+
const base = styleNumber(style, 'spacing', null)
|
|
265
|
+
const lr = base !== null ? base : 6
|
|
266
|
+
const tb = base !== null ? base : 0
|
|
267
|
+
return {
|
|
268
|
+
left: styleNumber(style, 'spacingLeft', section ? 10 : lr),
|
|
269
|
+
right: styleNumber(style, 'spacingRight', lr),
|
|
270
|
+
top: styleNumber(style, 'spacingTop', section ? 6 : tb),
|
|
271
|
+
bottom: styleNumber(style, 'spacingBottom', tb),
|
|
272
|
+
align: styleGet(style, 'align', section || shape === 'swimlane' ? 'left' : 'center'),
|
|
273
|
+
verticalAlign: styleGet(style, 'verticalAlign', section ? 'top' : 'middle'),
|
|
274
|
+
labelPosition: styleGet(style, 'labelPosition', 'center'),
|
|
275
|
+
verticalLabelPosition: styleGet(style, 'verticalLabelPosition', 'middle'),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function swimlaneStartSize(node) {
|
|
280
|
+
const n = styleNumber(styleOf(node), 'startSize', 23)
|
|
281
|
+
return n > 0 ? n : 23
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function labelTextWidth(node, w) {
|
|
285
|
+
const insets = labelInsetsOf(node)
|
|
286
|
+
const shape = shapeOf(node)
|
|
287
|
+
const inner = Math.max(12, w - insets.left - insets.right)
|
|
288
|
+
if (shape === 'diamond') return Math.max(12, inner * 0.6)
|
|
289
|
+
if (shape === 'ellipse') return Math.max(12, inner * 0.72)
|
|
290
|
+
return inner
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function imageContentRect(node, geo) {
|
|
294
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
295
|
+
const insets = labelInsetsOf(node)
|
|
296
|
+
const pos = insets.labelPosition
|
|
297
|
+
const vpos = insets.verticalLabelPosition
|
|
298
|
+
if (label.length === 0 || (pos === 'center' && vpos === 'middle')) {
|
|
299
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
300
|
+
}
|
|
301
|
+
if (pos === 'center' && (vpos === 'top' || vpos === 'bottom')) {
|
|
302
|
+
const band = Math.max(labelLineHeight(FSIZE), Math.min(geo.h * 0.35, 28))
|
|
303
|
+
if (band > 0) {
|
|
304
|
+
return { x: geo.x, y: vpos === 'top' ? geo.y + band : geo.y, w: geo.w, h: Math.max(1, geo.h - band) }
|
|
305
|
+
}
|
|
306
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
307
|
+
}
|
|
308
|
+
if (pos === 'right' || pos === 'left') {
|
|
309
|
+
const split = Math.max(Math.min(geo.h, geo.w * 0.45), Math.min(geo.w * 0.5, 28))
|
|
310
|
+
if (pos === 'right') return { x: geo.x, y: geo.y, w: split, h: geo.h }
|
|
311
|
+
return { x: geo.x + geo.w - split, y: geo.y, w: split, h: geo.h }
|
|
312
|
+
}
|
|
313
|
+
if (pos === 'bottom' || pos === 'top') {
|
|
314
|
+
const split = Math.max(Math.min(geo.w, geo.h * 0.45), Math.min(geo.h * 0.5, 28))
|
|
315
|
+
if (pos === 'bottom') return { x: geo.x, y: geo.y, w: geo.w, h: split }
|
|
316
|
+
return { x: geo.x, y: geo.y + geo.h - split, w: geo.w, h: split }
|
|
317
|
+
}
|
|
318
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function labelTextBox(box) {
|
|
322
|
+
const geo = box.geo
|
|
323
|
+
const node = box.node
|
|
324
|
+
const style = styleOf(node)
|
|
325
|
+
const insets = labelInsetsOf(node)
|
|
326
|
+
const fontSize = numberOr(box.fontSize, styleNumber(style, 'fontSize', FSIZE))
|
|
327
|
+
const lineHeight = labelLineHeight(fontSize)
|
|
328
|
+
const text = typeof box.text === 'string' ? box.text : typeof node.label === 'string' ? node.label : ''
|
|
329
|
+
const shape = shapeOf(node)
|
|
330
|
+
|
|
331
|
+
let areaX = geo.x
|
|
332
|
+
let areaY = geo.y
|
|
333
|
+
let areaW = geo.w
|
|
334
|
+
let areaH = geo.h
|
|
335
|
+
if (shape === 'swimlane') {
|
|
336
|
+
areaH = Math.min(geo.h, swimlaneStartSize(node))
|
|
337
|
+
} else if ((shape === 'image' || shape === 'svg') && text.length > 0) {
|
|
338
|
+
const pos = insets.labelPosition
|
|
339
|
+
const vpos = insets.verticalLabelPosition
|
|
340
|
+
if (pos === 'right' || pos === 'left') {
|
|
341
|
+
const img = imageContentRect(node, geo)
|
|
342
|
+
if (pos === 'right') {
|
|
343
|
+
areaX = img.x + img.w
|
|
344
|
+
areaW = Math.max(12, geo.w - img.w)
|
|
345
|
+
} else {
|
|
346
|
+
areaW = Math.max(12, geo.w - img.w)
|
|
347
|
+
}
|
|
348
|
+
} else if (pos === 'bottom' || pos === 'top') {
|
|
349
|
+
const img = imageContentRect(node, geo)
|
|
350
|
+
if (pos === 'bottom') {
|
|
351
|
+
areaY = img.y + img.h
|
|
352
|
+
areaH = Math.max(lineHeight, geo.h - img.h)
|
|
353
|
+
} else {
|
|
354
|
+
areaH = Math.max(lineHeight, geo.h - img.h)
|
|
355
|
+
}
|
|
356
|
+
} else if (vpos === 'top' || vpos === 'bottom') {
|
|
357
|
+
const band = Math.max(lineHeight + insets.top + insets.bottom, Math.min(geo.h * 0.35, 28))
|
|
358
|
+
if (vpos === 'top') areaH = band
|
|
359
|
+
else {
|
|
360
|
+
areaY = geo.y + geo.h - band
|
|
361
|
+
areaH = band
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const w = Math.max(12, labelTextWidth(Object.assign({}, node, { w: areaW, h: areaH }), areaW))
|
|
367
|
+
const cols = labelWraps(node) ? wrapLabel(text, w, fontSize).length : String(text).split('\n').length
|
|
368
|
+
const h = Math.max(lineHeight * cols, lineHeight)
|
|
369
|
+
|
|
370
|
+
const innerX = areaX + insets.left
|
|
371
|
+
const innerRight = areaX + areaW - insets.right
|
|
372
|
+
const innerY = areaY + insets.top
|
|
373
|
+
const innerBottom = areaY + areaH - insets.bottom
|
|
374
|
+
let x
|
|
375
|
+
if (insets.align === 'left') x = innerX
|
|
376
|
+
else if (insets.align === 'right') x = innerRight - w
|
|
377
|
+
else x = (innerX + innerRight - w) / 2
|
|
378
|
+
let y
|
|
379
|
+
if (insets.verticalAlign === 'top') y = innerY
|
|
380
|
+
else if (insets.verticalAlign === 'bottom') y = innerBottom - h
|
|
381
|
+
else y = (innerY + innerBottom - h) / 2
|
|
382
|
+
|
|
383
|
+
return { x: x, y: y, w: w, h: h, fontSize: fontSize, lineHeight: lineHeight, align: insets.align, verticalAlign: insets.verticalAlign }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 标签文字实际占的框(换行后最宽一行 + 行数),用于导出包围盒。 */
|
|
387
|
+
function labelPaintBounds(node, geo) {
|
|
388
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
389
|
+
if (label.length === 0) return null
|
|
390
|
+
if (isVideoNode(node)) return null
|
|
391
|
+
const style = styleOf(node)
|
|
392
|
+
const fontSize = styleNumber(style, 'fontSize', FSIZE)
|
|
393
|
+
const lineHeight = labelLineHeight(fontSize)
|
|
394
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: fontSize })
|
|
395
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, fontSize) : label.split('\n')
|
|
396
|
+
let widest = 0
|
|
397
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
398
|
+
const w = textWidth(lines[i], fontSize)
|
|
399
|
+
if (w > widest) widest = w
|
|
400
|
+
}
|
|
401
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
402
|
+
const cx = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
403
|
+
const first = box.y + lineHeight / 2
|
|
404
|
+
return {
|
|
405
|
+
minX: align === 'left' ? cx : align === 'right' ? cx - widest : cx - widest / 2,
|
|
406
|
+
maxX: align === 'left' ? cx + widest : align === 'right' ? cx : cx + widest / 2,
|
|
407
|
+
minY: first - lineHeight / 2,
|
|
408
|
+
maxY: first + (lines.length - 1) * lineHeight + lineHeight / 2,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 节点"装得下自己的文字"需要多高(src/client.js:1272)。
|
|
414
|
+
*
|
|
415
|
+
* 与客户端同一个口径,是为了让导出里被文字撑高的节点**也**按撑高后的高度画,
|
|
416
|
+
* 否则多行标签的下半截会落到框外。
|
|
417
|
+
*/
|
|
418
|
+
function nodeTextFitHeight(node) {
|
|
419
|
+
if (!labelWraps(node)) return 0
|
|
420
|
+
const shape = shapeOf(node)
|
|
421
|
+
if (shape === 'image') return 0
|
|
422
|
+
if (shape === 'text' || shape === 'section' || shape === 'swimlane' || shape === 'svg' || shape === 'video') return 0
|
|
423
|
+
const w = numberOr(node.w, FALLBACK_NODE_W)
|
|
424
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
425
|
+
if (label.length === 0) return 0
|
|
426
|
+
const fontSize = styleNumber(styleOf(node), 'fontSize', FSIZE)
|
|
427
|
+
const lines = wrapLabel(label, labelTextWidth(node, w), fontSize).length
|
|
428
|
+
if (lines <= 1) return 0
|
|
429
|
+
return (lines + 1) * labelLineHeight(fontSize)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function nodeBoxOf(node) {
|
|
433
|
+
const w = numberOr(node.w, FALLBACK_NODE_W)
|
|
434
|
+
const stored = numberOr(node.h, FALLBACK_NODE_H)
|
|
435
|
+
const need = nodeTextFitHeight(node)
|
|
436
|
+
if (need > stored) return { x: numberOr(node.x, 0), y: numberOr(node.y, 0), w: w, h: snapTo(need, GRID) }
|
|
437
|
+
return { x: numberOr(node.x, 0), y: numberOr(node.y, 0), w: w, h: stored }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 视频节点由客户端组件画(帧序列/播放器),导出只给通用形状 —— 也不吃标签。 */
|
|
441
|
+
function isVideoNode(node) {
|
|
442
|
+
const data = node !== null && typeof node === 'object' ? node.data : null
|
|
443
|
+
return data !== null && typeof data === 'object' && (typeof data.videoClip === 'string' || typeof data.motion === 'object')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── 路径序列化(src/client.js:2469-2507)───────────────────────────────
|
|
447
|
+
|
|
448
|
+
function dedupePoints(points) {
|
|
449
|
+
const out = []
|
|
450
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
451
|
+
const p = points[i]
|
|
452
|
+
if (out.length > 0) {
|
|
453
|
+
const prev = out[out.length - 1]
|
|
454
|
+
if (Math.abs(prev.x - p.x) < 0.01 && Math.abs(prev.y - p.y) < 0.01) continue
|
|
455
|
+
}
|
|
456
|
+
out.push({ x: p.x, y: p.y })
|
|
457
|
+
}
|
|
458
|
+
return out
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function pathOf(rawPoints, radius, curved) {
|
|
462
|
+
const pts = dedupePoints(rawPoints)
|
|
463
|
+
if (pts.length < 2) return ''
|
|
464
|
+
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })
|
|
465
|
+
if (curved === true) {
|
|
466
|
+
if (pts.length === 2) return 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y) + ' L ' + fmt(pts[1].x) + ' ' + fmt(pts[1].y)
|
|
467
|
+
let d = 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y)
|
|
468
|
+
for (let i = 1; i < pts.length - 1; i += 1) {
|
|
469
|
+
const end = i === pts.length - 2 ? pts[pts.length - 1] : mid(pts[i], pts[i + 1])
|
|
470
|
+
d += ' Q ' + fmt(pts[i].x) + ' ' + fmt(pts[i].y) + ' ' + fmt(end.x) + ' ' + fmt(end.y)
|
|
471
|
+
}
|
|
472
|
+
return d
|
|
473
|
+
}
|
|
474
|
+
const r = Math.max(0, numberOr(radius, 0))
|
|
475
|
+
let d = 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y)
|
|
476
|
+
for (let i = 1; i < pts.length - 1; i += 1) {
|
|
477
|
+
const prev = pts[i - 1]
|
|
478
|
+
const cur = pts[i]
|
|
479
|
+
const next = pts[i + 1]
|
|
480
|
+
if (r <= 0) {
|
|
481
|
+
d += ' L ' + fmt(cur.x) + ' ' + fmt(cur.y)
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
const d1 = Math.abs(cur.x - prev.x) + Math.abs(cur.y - prev.y)
|
|
485
|
+
const d2 = Math.abs(next.x - cur.x) + Math.abs(next.y - cur.y)
|
|
486
|
+
const rr = Math.min(r, d1 / 2, d2 / 2)
|
|
487
|
+
if (rr <= 0.01) {
|
|
488
|
+
d += ' L ' + fmt(cur.x) + ' ' + fmt(cur.y)
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
const len1 = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1
|
|
492
|
+
const len2 = Math.hypot(next.x - cur.x, next.y - cur.y) || 1
|
|
493
|
+
const a = { x: cur.x - ((cur.x - prev.x) / len1) * rr, y: cur.y - ((cur.y - prev.y) / len1) * rr }
|
|
494
|
+
const b = { x: cur.x + ((next.x - cur.x) / len2) * rr, y: cur.y + ((next.y - cur.y) / len2) * rr }
|
|
495
|
+
d += ' L ' + fmt(a.x) + ' ' + fmt(a.y) + ' Q ' + fmt(cur.x) + ' ' + fmt(cur.y) + ' ' + fmt(b.x) + ' ' + fmt(b.y)
|
|
496
|
+
}
|
|
497
|
+
const last = pts[pts.length - 1]
|
|
498
|
+
d += ' L ' + fmt(last.x) + ' ' + fmt(last.y)
|
|
499
|
+
return d
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── 连线上文字的落点与"给字让路"(src/client.js:5924-6196)──────────────
|
|
503
|
+
|
|
504
|
+
function arcLengths(pts) {
|
|
505
|
+
const segs = []
|
|
506
|
+
let total = 0
|
|
507
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
508
|
+
const len = Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y)
|
|
509
|
+
segs.push(len)
|
|
510
|
+
total += len
|
|
511
|
+
}
|
|
512
|
+
return { segs: segs, total: total }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pointAtArc(pts, segs, d) {
|
|
516
|
+
let acc = 0
|
|
517
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
518
|
+
const seg = segs[i]
|
|
519
|
+
if (d <= acc + seg || i === segs.length - 1) {
|
|
520
|
+
const t = seg === 0 ? 0 : Math.max(0, Math.min(1, (d - acc) / seg))
|
|
521
|
+
return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }
|
|
522
|
+
}
|
|
523
|
+
acc += seg
|
|
524
|
+
}
|
|
525
|
+
return { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** drawio 的边标签落点:沿弧长的中点,拖过就按 labelX/labelY 的相对量。 */
|
|
529
|
+
function edgeLabelPointAt(pts, x, y, offsetX, offsetY) {
|
|
530
|
+
if (Array.isArray(pts) === false || pts.length < 2) return null
|
|
531
|
+
const segs = []
|
|
532
|
+
let total = 0
|
|
533
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
534
|
+
const len = Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y)
|
|
535
|
+
segs.push(len)
|
|
536
|
+
total += len
|
|
537
|
+
}
|
|
538
|
+
if (total <= 0) return null
|
|
539
|
+
const gx = (Number.isFinite(Number(x)) ? Number(x) : 0) / 2
|
|
540
|
+
let dist = Math.round((gx + 0.5) * total)
|
|
541
|
+
if (dist < 0) dist = 0
|
|
542
|
+
if (dist > total) dist = total
|
|
543
|
+
let acc = 0
|
|
544
|
+
let index = 0
|
|
545
|
+
while (index < segs.length - 1 && dist >= Math.round(acc + segs[index])) {
|
|
546
|
+
acc += segs[index]
|
|
547
|
+
index += 1
|
|
548
|
+
}
|
|
549
|
+
const seg = segs[index]
|
|
550
|
+
const factor = seg === 0 ? 0 : (dist - acc) / seg
|
|
551
|
+
const p0 = pts[index]
|
|
552
|
+
const pe = pts[index + 1]
|
|
553
|
+
if (p0 === undefined || pe === undefined) return null
|
|
554
|
+
const dx = pe.x - p0.x
|
|
555
|
+
const dy = pe.y - p0.y
|
|
556
|
+
const nx = seg === 0 ? 0 : dy / seg
|
|
557
|
+
const ny = seg === 0 ? 0 : dx / seg
|
|
558
|
+
const perp = Number.isFinite(Number(y)) ? Number(y) : 0
|
|
559
|
+
const ox = Number.isFinite(Number(offsetX)) ? Number(offsetX) : 0
|
|
560
|
+
const oy = Number.isFinite(Number(offsetY)) ? Number(offsetY) : 0
|
|
561
|
+
return { x: p0.x + dx * factor + (nx * perp + ox), y: p0.y + dy * factor - (ny * perp - oy) }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function labelBox(pos, text, fontSize) {
|
|
565
|
+
if (pos === null || pos === undefined) return null
|
|
566
|
+
const lines = String(text || '').split('\n')
|
|
567
|
+
let widest = 0
|
|
568
|
+
for (let i = 0; i < lines.length; i += 1) widest = Math.max(widest, textWidth(lines[i], fontSize))
|
|
569
|
+
const w = widest + 6
|
|
570
|
+
const h = labelLineHeight(fontSize) * lines.length + 2
|
|
571
|
+
return { x: pos.x - w / 2, y: pos.y + 1 - h / 2, w: w, h: h }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const growBox = (box, pad) => ({ x: box.x - pad, y: box.y - pad, w: box.w + pad * 2, h: box.h + pad * 2 })
|
|
575
|
+
|
|
576
|
+
/** 标签压在线上时,线要在它这块区域里断开 —— drawio 的观感:字的位置不画线。 */
|
|
577
|
+
function labelGapsOnPath(pts, box) {
|
|
578
|
+
if (Array.isArray(pts) === false || pts.length < 2) return []
|
|
579
|
+
if (box === null || box === undefined) return []
|
|
580
|
+
const segs = arcLengths(pts).segs
|
|
581
|
+
const gaps = []
|
|
582
|
+
let acc = 0
|
|
583
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
584
|
+
const a = pts[i]
|
|
585
|
+
const b = pts[i + 1]
|
|
586
|
+
const seg = segs[i]
|
|
587
|
+
if (seg > 0) {
|
|
588
|
+
if (Math.abs(a.y - b.y) < 0.5) {
|
|
589
|
+
if (a.y >= box.y && a.y <= box.y + box.h) {
|
|
590
|
+
const from = Math.max(Math.min(a.x, b.x), box.x)
|
|
591
|
+
const to = Math.min(Math.max(a.x, b.x), box.x + box.w)
|
|
592
|
+
if (to > from) {
|
|
593
|
+
const s0 = a.x <= b.x ? acc + (from - a.x) : acc + (a.x - from)
|
|
594
|
+
const s1 = a.x <= b.x ? acc + (to - a.x) : acc + (a.x - to)
|
|
595
|
+
gaps.push({ from: Math.min(s0, s1), to: Math.max(s0, s1) })
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} else if (Math.abs(a.x - b.x) < 0.5) {
|
|
599
|
+
if (a.x >= box.x && a.x <= box.x + box.w) {
|
|
600
|
+
const from = Math.max(Math.min(a.y, b.y), box.y)
|
|
601
|
+
const to = Math.min(Math.max(a.y, b.y), box.y + box.h)
|
|
602
|
+
if (to > from) {
|
|
603
|
+
const s0 = a.y <= b.y ? acc + (from - a.y) : acc + (a.y - from)
|
|
604
|
+
const s1 = a.y <= b.y ? acc + (to - a.y) : acc + (a.y - to)
|
|
605
|
+
gaps.push({ from: Math.min(s0, s1), to: Math.max(s0, s1) })
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
acc += seg
|
|
611
|
+
}
|
|
612
|
+
gaps.sort((x, y) => x.from - y.from)
|
|
613
|
+
const merged = []
|
|
614
|
+
for (let i = 0; i < gaps.length; i += 1) {
|
|
615
|
+
const last = merged[merged.length - 1]
|
|
616
|
+
if (last !== undefined && gaps[i].from <= last.to) last.to = Math.max(last.to, gaps[i].to)
|
|
617
|
+
else merged.push({ from: gaps[i].from, to: gaps[i].to })
|
|
618
|
+
}
|
|
619
|
+
return merged
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 按弧长区间把折线挖空;两端各留一小截,免得长标签把整条线(连箭头)吃掉。 */
|
|
623
|
+
function cutPathByGaps(pts, gaps) {
|
|
624
|
+
if (Array.isArray(gaps) === false || gaps.length === 0) return [pts]
|
|
625
|
+
const lengths = arcLengths(pts)
|
|
626
|
+
const segs = lengths.segs
|
|
627
|
+
const total = lengths.total
|
|
628
|
+
if (total <= 0) return [pts]
|
|
629
|
+
const keep = Math.min(8, total * 0.2)
|
|
630
|
+
const kept = []
|
|
631
|
+
let cursor = 0
|
|
632
|
+
for (let i = 0; i < gaps.length; i += 1) {
|
|
633
|
+
const from = Math.max(keep, gaps[i].from)
|
|
634
|
+
const to = Math.min(total - keep, gaps[i].to)
|
|
635
|
+
if (to <= from) continue
|
|
636
|
+
if (from > cursor) kept.push([cursor, from])
|
|
637
|
+
cursor = Math.max(cursor, to)
|
|
638
|
+
}
|
|
639
|
+
if (cursor < total) kept.push([cursor, total])
|
|
640
|
+
const out = []
|
|
641
|
+
for (let k = 0; k < kept.length; k += 1) {
|
|
642
|
+
const a = kept[k][0]
|
|
643
|
+
const b = kept[k][1]
|
|
644
|
+
if (b - a <= 0.01) continue
|
|
645
|
+
const sub = [pointAtArc(pts, segs, a)]
|
|
646
|
+
let acc = 0
|
|
647
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
648
|
+
acc += segs[i]
|
|
649
|
+
if (acc > a + 0.01 && acc < b - 0.01) sub.push({ x: pts[i + 1].x, y: pts[i + 1].y })
|
|
650
|
+
}
|
|
651
|
+
sub.push(pointAtArc(pts, segs, b))
|
|
652
|
+
out.push(sub)
|
|
653
|
+
}
|
|
654
|
+
return out.length > 0 ? out : [pts]
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── 形状(src/client.js:2509-2731)──────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
function shapeSvg(node, geo, palette, warnings) {
|
|
660
|
+
const style = styleOf(node)
|
|
661
|
+
const shape = shapeOf(node)
|
|
662
|
+
const colors = colorOf(node, palette)
|
|
663
|
+
const common = {
|
|
664
|
+
fill: colors.fill,
|
|
665
|
+
stroke: colors.stroke,
|
|
666
|
+
'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
667
|
+
}
|
|
668
|
+
const fillOpacity = Math.max(0, Math.min(100, styleNumber(style, 'fillOpacity', 100))) / 100
|
|
669
|
+
const strokeOpacity = Math.max(0, Math.min(100, styleNumber(style, 'strokeOpacity', 100))) / 100
|
|
670
|
+
if (fillOpacity < 1) common['fill-opacity'] = fmt(fillOpacity)
|
|
671
|
+
if (strokeOpacity < 1) common['stroke-opacity'] = fmt(strokeOpacity)
|
|
672
|
+
const dash = dashPatternFromStyle(style)
|
|
673
|
+
if (dash !== null) common['stroke-dasharray'] = dash
|
|
674
|
+
|
|
675
|
+
const x = geo.x
|
|
676
|
+
const y = geo.y
|
|
677
|
+
const w = geo.w
|
|
678
|
+
const h = geo.h
|
|
679
|
+
|
|
680
|
+
if (shape === 'text') {
|
|
681
|
+
return ''
|
|
682
|
+
}
|
|
683
|
+
if (shape === 'section') {
|
|
684
|
+
const section = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h), rx: 4, ry: 4 })
|
|
685
|
+
if (styleGet(style, 'fillColor', null) === null) section.fill = '#f5f5f5'
|
|
686
|
+
if (dash === null) section['stroke-dasharray'] = '6 3'
|
|
687
|
+
return '<rect' + attrs(section) + '/>'
|
|
688
|
+
}
|
|
689
|
+
if (shape === 'swimlane') {
|
|
690
|
+
const startSize = swimlaneStartSize(node)
|
|
691
|
+
const header = Math.min(h, startSize)
|
|
692
|
+
const body = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h) })
|
|
693
|
+
if (styleGet(style, 'fillColor', null) === null) body.fill = '#ffffff'
|
|
694
|
+
const headFill = styleGet(style, 'swimlaneFillColor', null)
|
|
695
|
+
const parts = ['<rect' + attrs(body) + '/>']
|
|
696
|
+
parts.push('<rect' + attrs({
|
|
697
|
+
x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(header),
|
|
698
|
+
fill: headFill !== null ? headFill : body.fill, stroke: 'none',
|
|
699
|
+
}) + '/>')
|
|
700
|
+
parts.push('<line' + attrs({
|
|
701
|
+
x1: fmt(x), y1: fmt(y + header), x2: fmt(x + w), y2: fmt(y + header),
|
|
702
|
+
stroke: colors.stroke, 'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
703
|
+
}) + '/>')
|
|
704
|
+
return parts.join('')
|
|
705
|
+
}
|
|
706
|
+
if (shape === 'svg' || shape === 'image') {
|
|
707
|
+
const href = imageValueFromStyle(style)
|
|
708
|
+
const rect = imageContentRect(node, geo)
|
|
709
|
+
const usable = typeof href === 'string' && /^data:image\//i.test(href)
|
|
710
|
+
if (usable === false) {
|
|
711
|
+
warnings.push('图片/SVG 节点 "' + String(node.id) + '" 的 image= 不可解析,导出为虚线占位框')
|
|
712
|
+
return '<rect' + attrs({
|
|
713
|
+
x: fmt(rect.x + 0.5), y: fmt(rect.y + 0.5), width: fmt(Math.max(1, rect.w - 1)), height: fmt(Math.max(1, rect.h - 1)),
|
|
714
|
+
fill: 'none', stroke: colors.stroke, 'stroke-width': 1, 'stroke-dasharray': '3 3',
|
|
715
|
+
}) + '/>'
|
|
716
|
+
}
|
|
717
|
+
// imageAspect=1 等比居中、0/缺省拉伸铺满(drawio 语义);位图缺省 1、svg 缺省 0。
|
|
718
|
+
const fit = styleGet(style, 'imageAspect', shape === 'image' ? '1' : '0') === '1'
|
|
719
|
+
return '<image' + attrs({
|
|
720
|
+
x: fmt(rect.x), y: fmt(rect.y), width: fmt(rect.w), height: fmt(rect.h),
|
|
721
|
+
href: href, 'preserveAspectRatio': fit ? 'xMidYMid meet' : 'none',
|
|
722
|
+
}) + '/>'
|
|
723
|
+
}
|
|
724
|
+
if (shape === 'ellipse') {
|
|
725
|
+
return '<ellipse' + attrs(Object.assign({}, common, {
|
|
726
|
+
cx: fmt(x + w / 2), cy: fmt(y + h / 2), rx: fmt(w / 2), ry: fmt(h / 2),
|
|
727
|
+
})) + '/>'
|
|
728
|
+
}
|
|
729
|
+
if (shape === 'diamond') {
|
|
730
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
731
|
+
points: [x + w / 2, y, x + w, y + h / 2, x + w / 2, y + h, x, y + h / 2].map(fmt).join(' '),
|
|
732
|
+
})) + '/>'
|
|
733
|
+
}
|
|
734
|
+
if (shape === 'parallelogram') {
|
|
735
|
+
const s = Math.min(w * 0.2, 24)
|
|
736
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
737
|
+
points: [x + s, y, x + w, y, x + w - s, y + h, x, y + h].map(fmt).join(' '),
|
|
738
|
+
})) + '/>'
|
|
739
|
+
}
|
|
740
|
+
if (shape === 'hexagon') {
|
|
741
|
+
const s = Math.min(w * 0.15, 20)
|
|
742
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
743
|
+
points: [x + s, y, x + w - s, y, x + w, y + h / 2, x + w - s, y + h, x + s, y + h, x, y + h / 2].map(fmt).join(' '),
|
|
744
|
+
})) + '/>'
|
|
745
|
+
}
|
|
746
|
+
if (shape === 'cylinder') {
|
|
747
|
+
const ry = Math.min(h * 0.18, 14)
|
|
748
|
+
const body = ['M', fmt(x), fmt(y + ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '1', fmt(x + w), fmt(y + ry),
|
|
749
|
+
'L', fmt(x + w), fmt(y + h - ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '1', fmt(x), fmt(y + h - ry), 'Z'].join(' ')
|
|
750
|
+
const lid = ['M', fmt(x), fmt(y + ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '0', fmt(x + w), fmt(y + ry)].join(' ')
|
|
751
|
+
return '<path' + attrs(Object.assign({}, common, { d: body })) + '/>' +
|
|
752
|
+
'<path' + attrs(Object.assign({}, common, { d: lid, fill: 'none' })) + '/>'
|
|
753
|
+
}
|
|
754
|
+
if (shape === 'document') {
|
|
755
|
+
const d = ['M', fmt(x), fmt(y), 'L', fmt(x + w), fmt(y), 'L', fmt(x + w), fmt(y + h * 0.82),
|
|
756
|
+
'C', fmt(x + w * 0.75), fmt(y + h * 1.06), fmt(x + w * 0.25), fmt(y + h * 0.58), fmt(x), fmt(y + h * 0.86), 'Z'].join(' ')
|
|
757
|
+
return '<path' + attrs(Object.assign({}, common, { d: d })) + '/>'
|
|
758
|
+
}
|
|
759
|
+
// 其余(rect / rounded / stadium):drawio 的圆角是 rounded=1 + arcSize(百分比,缺省 15%)。
|
|
760
|
+
let rx = 0
|
|
761
|
+
if (shape === 'rounded' || shape === 'stadium') {
|
|
762
|
+
const arc = styleNumber(style, 'arcSize', null)
|
|
763
|
+
rx = shape === 'stadium' ? h / 2 : Math.min(w, h) * (arc === null ? 0.15 : arc / 100)
|
|
764
|
+
}
|
|
765
|
+
const rect = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h) })
|
|
766
|
+
if (rx > 0) {
|
|
767
|
+
rect.rx = fmt(rx)
|
|
768
|
+
rect.ry = fmt(rx)
|
|
769
|
+
}
|
|
770
|
+
return '<rect' + attrs(rect) + '/>'
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── 文字 ────────────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 一行文字 → `<text>` + 每行一个 `<tspan>`。
|
|
777
|
+
*
|
|
778
|
+
* 基线偏移手算(`top + lineHeight/2 + 0.35em`)而不用 `dominant-baseline="middle"`:
|
|
779
|
+
* 浏览器、resvg、Inkscape 对那个属性各有各的解释,手算的版本在哪都一样。
|
|
780
|
+
*/
|
|
781
|
+
function textSvg(spec) {
|
|
782
|
+
const lines = Array.isArray(spec.lines) ? spec.lines : ['']
|
|
783
|
+
const fontSize = numberOr(spec.fontSize, FSIZE)
|
|
784
|
+
const lineHeight = numberOr(spec.lineHeight, labelLineHeight(fontSize))
|
|
785
|
+
const baseline = numberOr(spec.top, 0) + lineHeight / 2 + fontSize * 0.35
|
|
786
|
+
const spans = lines.map((line, i) => '<tspan' + attrs({ x: fmt(spec.x), dy: i === 0 ? 0 : fmt(lineHeight) }) + '>' +
|
|
787
|
+
escapeXml(line) + '</tspan>').join('')
|
|
788
|
+
const opacity = numberOr(spec.opacity, 1)
|
|
789
|
+
return '<text' + attrs({
|
|
790
|
+
x: fmt(spec.x),
|
|
791
|
+
y: fmt(baseline),
|
|
792
|
+
'text-anchor': spec.anchor === 'start' || spec.anchor === 'end' ? spec.anchor : 'middle',
|
|
793
|
+
'font-family': spec.family || SVG_FONT,
|
|
794
|
+
'font-size': fmt(fontSize),
|
|
795
|
+
fill: spec.fill,
|
|
796
|
+
'fill-opacity': opacity < 1 ? fmt(opacity) : null,
|
|
797
|
+
'xml:space': 'preserve',
|
|
798
|
+
}) + '>' + spans + '</text>'
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function nodeLabelSvg(node, geo, palette) {
|
|
802
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
803
|
+
if (label.length === 0) return ''
|
|
804
|
+
if (isVideoNode(node)) return ''
|
|
805
|
+
const style = styleOf(node)
|
|
806
|
+
const shape = shapeOf(node)
|
|
807
|
+
if (shape === 'text') {
|
|
808
|
+
// 独立文字:没有框,直接按标签盒居中画(与客户端同一套 labelTextBox)。
|
|
809
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: styleNumber(style, 'fontSize', FSIZE) })
|
|
810
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, box.fontSize) : label.split('\n')
|
|
811
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
812
|
+
const x = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
813
|
+
return textSvg({
|
|
814
|
+
x: x, top: box.y, lines: lines, fontSize: box.fontSize, lineHeight: box.lineHeight,
|
|
815
|
+
anchor: align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle',
|
|
816
|
+
fill: colorOf(node, palette).font,
|
|
817
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
const fontSize = styleNumber(style, 'fontSize', FSIZE)
|
|
821
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: fontSize })
|
|
822
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, fontSize) : String(label).split('\n')
|
|
823
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
824
|
+
const x = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
825
|
+
return textSvg({
|
|
826
|
+
x: x, top: box.y, lines: lines, fontSize: fontSize, lineHeight: box.lineHeight,
|
|
827
|
+
anchor: align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle',
|
|
828
|
+
fill: colorOf(node, palette).font,
|
|
829
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── 连线 ────────────────────────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
function hiddenLayerIds(doc) {
|
|
836
|
+
const set = new Set()
|
|
837
|
+
const layers = Array.isArray(doc.layers) ? doc.layers : []
|
|
838
|
+
for (let i = 0; i < layers.length; i += 1) {
|
|
839
|
+
const layer = layers[i]
|
|
840
|
+
if (layer !== null && typeof layer === 'object' && layer.visible === false) set.add(String(layer.id))
|
|
841
|
+
}
|
|
842
|
+
return set
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function isHiddenCell(hidden, cell) {
|
|
846
|
+
return cell.layer !== undefined && cell.layer !== null && hidden.has(String(cell.layer))
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** 节点几何:文档里的宽高 + 被文字撑高的那一份(客户端 nodeBoxOf 的宿主版)。 */
|
|
850
|
+
export function exportBoxes(doc) {
|
|
851
|
+
const hidden = hiddenLayerIds(doc)
|
|
852
|
+
const boxes = []
|
|
853
|
+
const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
|
|
854
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
855
|
+
const node = nodes[i]
|
|
856
|
+
if (node === null || typeof node !== 'object') continue
|
|
857
|
+
if (isHiddenCell(hidden, node)) continue
|
|
858
|
+
boxes.push({ id: String(node.id), node: node, geo: nodeBoxOf(node) })
|
|
859
|
+
}
|
|
860
|
+
return boxes
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 把一条带用户折点的连线**掰成直角**(客户端 `routeThroughWaypoints` 的简化版)。
|
|
865
|
+
*
|
|
866
|
+
* 为什么需要:`routeEdgeStyled` 对"手动/普通正交"的边不直接画存下来的折点,而是
|
|
867
|
+
* 逐个折点补拐角 —— 上一段横就先横后竖、上一段竖就先竖后横,最后一段垂直于落点所在的边。
|
|
868
|
+
* 导出若照抄折点,中间会多出斜线段(demo.drawio 里三条边都这样),与画布上看到的不是一个东西。
|
|
869
|
+
*
|
|
870
|
+
* 只处理"斜的"那一段:已经是横/竖的段原样保留,用户折点一个不丢、顺序也不变。
|
|
871
|
+
*/
|
|
872
|
+
function orthogonalizeRoute(points, style) {
|
|
873
|
+
if (Array.isArray(points) === false || points.length < 2) return points
|
|
874
|
+
const sourceSide = sideFromStyle(style, 'source')
|
|
875
|
+
const targetSide = sideFromStyle(style, 'target')
|
|
876
|
+
const sideIsHorizontal = (side) => side === 'e' || side === 'w'
|
|
877
|
+
const sideIsVertical = (side) => side === 'n' || side === 's'
|
|
878
|
+
const out = [{ x: points[0].x, y: points[0].y }]
|
|
879
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
880
|
+
const prev = out[out.length - 1]
|
|
881
|
+
const next = { x: points[i].x, y: points[i].y }
|
|
882
|
+
const dx = next.x - prev.x
|
|
883
|
+
const dy = next.y - prev.y
|
|
884
|
+
if (Math.abs(dx) < 0.01 || Math.abs(dy) < 0.01) {
|
|
885
|
+
out.push(next)
|
|
886
|
+
continue
|
|
887
|
+
}
|
|
888
|
+
// 拐弯方向跟着行进方向走:上一段是横的 → 先横后竖;是竖的 → 先竖后横。
|
|
889
|
+
let horizontalFirst
|
|
890
|
+
const last = out.length > 1 ? out[out.length - 2] : null
|
|
891
|
+
if (last !== null && Math.abs(prev.y - last.y) < 0.01) horizontalFirst = true
|
|
892
|
+
else if (last !== null && Math.abs(prev.x - last.x) < 0.01) horizontalFirst = false
|
|
893
|
+
else if (i === 1 && sideIsHorizontal(sourceSide)) horizontalFirst = true
|
|
894
|
+
else if (i === 1 && sideIsVertical(sourceSide)) horizontalFirst = false
|
|
895
|
+
else horizontalFirst = Math.abs(dx) >= Math.abs(dy)
|
|
896
|
+
// 最后一段必须垂直于落点所在的边:从左边进就横着进,从上面进就竖着进。
|
|
897
|
+
if (i === points.length - 1) {
|
|
898
|
+
if (sideIsHorizontal(targetSide)) horizontalFirst = false
|
|
899
|
+
else if (sideIsVertical(targetSide)) horizontalFirst = true
|
|
900
|
+
}
|
|
901
|
+
out.push(horizontalFirst ? { x: next.x, y: prev.y } : { x: prev.x, y: next.y })
|
|
902
|
+
out.push(next)
|
|
903
|
+
}
|
|
904
|
+
// 相邻重复点(折返时可能出现)去掉,否则 pathOf 的圆角会算出一段零长。
|
|
905
|
+
const dedup = [out[0]]
|
|
906
|
+
for (let i = 1; i < out.length; i += 1) {
|
|
907
|
+
const prev = dedup[dedup.length - 1]
|
|
908
|
+
if (Math.abs(out[i].x - prev.x) > 0.01 || Math.abs(out[i].y - prev.y) > 0.01) dedup.push(out[i])
|
|
909
|
+
}
|
|
910
|
+
return dedup
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const EDGE_SIDE_ORDER = ['e', 's', 'w', 'n']
|
|
914
|
+
|
|
915
|
+
function nextSideOf(side) {
|
|
916
|
+
const at = EDGE_SIDE_ORDER.indexOf(side)
|
|
917
|
+
return EDGE_SIDE_ORDER[(at < 0 ? 0 : at + 1) % EDGE_SIDE_ORDER.length]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 盒子某一侧中心落在边框上的点(自环的起止点,与客户端 sideBorderPoint:2191 同一口径)。 */
|
|
921
|
+
function sideCenterOf(geo, side) {
|
|
922
|
+
const cx = geo.x + geo.w / 2
|
|
923
|
+
const cy = geo.y + geo.h / 2
|
|
924
|
+
if (side === 'n') return { x: cx, y: geo.y }
|
|
925
|
+
if (side === 's') return { x: cx, y: geo.y + geo.h }
|
|
926
|
+
if (side === 'w') return { x: geo.x, y: cy }
|
|
927
|
+
return { x: geo.x + geo.w, y: cy }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** 从某一侧往外推 `inset` 的桩点:它决定首/末段所在的那条线(客户端 stubPointFor:1807)。 */
|
|
931
|
+
function stubPointOf(geo, side, inset) {
|
|
932
|
+
const cx = geo.x + geo.w / 2
|
|
933
|
+
const cy = geo.y + geo.h / 2
|
|
934
|
+
if (side === 'n') return { x: cx, y: geo.y - inset }
|
|
935
|
+
if (side === 's') return { x: cx, y: geo.y + geo.h + inset }
|
|
936
|
+
if (side === 'w') return { x: geo.x - inset, y: cy }
|
|
937
|
+
return { x: geo.x + geo.w + inset, y: cy }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* 自环:`from === to` 的边,绕回同一个盒子(客户端 `selfLoopPath`,src/client.js:2218)。
|
|
942
|
+
*
|
|
943
|
+
* 为什么不能走 storedRoute:两端在同一个盒子上,裁出来的边框点是同一个点,
|
|
944
|
+
* 折线只剩一个点 —— 整条边被丢掉,还会顺手报一句"两端都落不到任何节点上",
|
|
945
|
+
* 而画布上它明明画得好好的。
|
|
946
|
+
*
|
|
947
|
+
* 形状规则与客户端一致:一侧横、一侧竖(e → s)时,唯一拐点取"横那侧的 x + 竖那侧的 y";
|
|
948
|
+
* 两侧相对(e → w 走盒子上下、n → s 走盒子左右)时绕出去一圈。用户摆过折点的按折点穿过去。
|
|
949
|
+
*/
|
|
950
|
+
function selfLoopRoute(geo, style, waypoints) {
|
|
951
|
+
const horizontalSide = (side) => side === 'e' || side === 'w'
|
|
952
|
+
const source = sideFromStyle(style, 'source') || 'e'
|
|
953
|
+
let target = sideFromStyle(style, 'target') || nextSideOf(source)
|
|
954
|
+
if (target === source) target = nextSideOf(source)
|
|
955
|
+
const jetty = styleNumber(style, 'jettySize', 0)
|
|
956
|
+
const inset = Number.isFinite(jetty) && jetty > 0 ? jetty : 35
|
|
957
|
+
const p0 = sideCenterOf(geo, source)
|
|
958
|
+
const p1 = sideCenterOf(geo, target)
|
|
959
|
+
const s = stubPointOf(geo, source, inset)
|
|
960
|
+
const t = stubPointOf(geo, target, inset)
|
|
961
|
+
const pts = [p0, s]
|
|
962
|
+
const chain = Array.isArray(waypoints) ? waypoints : []
|
|
963
|
+
if (chain.length > 0) {
|
|
964
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
965
|
+
const point = chain[i]
|
|
966
|
+
if (point === null || typeof point !== 'object') continue
|
|
967
|
+
const x = Number(point.x)
|
|
968
|
+
const y = Number(point.y)
|
|
969
|
+
if (Number.isFinite(x) && Number.isFinite(y)) pts.push({ x: x, y: y })
|
|
970
|
+
}
|
|
971
|
+
} else if (horizontalSide(source) !== horizontalSide(target)) {
|
|
972
|
+
const horizontal = horizontalSide(source) ? s : t
|
|
973
|
+
const vertical = horizontalSide(source) ? t : s
|
|
974
|
+
pts.push({ x: horizontal.x, y: vertical.y })
|
|
975
|
+
} else if (horizontalSide(source)) {
|
|
976
|
+
// 相对的左右两侧:从盒子上/下绕过去(e → w 走上边,w → e 走下边)。
|
|
977
|
+
const viaY = source === 'e' ? geo.y - inset : geo.y + geo.h + inset
|
|
978
|
+
pts.push({ x: s.x, y: viaY }, { x: t.x, y: viaY })
|
|
979
|
+
} else {
|
|
980
|
+
// 相对的上下两侧:从盒子左/右绕过去(n → s 走右边,s → n 走左边)。
|
|
981
|
+
const viaX = source === 'n' ? geo.x + geo.w + inset : geo.x - inset
|
|
982
|
+
pts.push({ x: viaX, y: s.y }, { x: viaX, y: t.y })
|
|
983
|
+
}
|
|
984
|
+
// 桩点 t 始终保留:末段要从 target 那侧的法线方向回到边框,少了它最后一段会斜着插进盒子。
|
|
985
|
+
pts.push(t, p1)
|
|
986
|
+
const orthogonal = isOrthogonalEdgeStyle(style) === true && curvedFromStyle(style) !== true
|
|
987
|
+
return orthogonal ? orthogonalizeRoute(dedupePoints(pts), style) : dedupePoints(pts)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function routeForEdge(edge, boxes, state) {
|
|
991
|
+
const own = Array.isArray(edge.points) ? edge.points : []
|
|
992
|
+
// storedRoute 用严格相等找端点,文档里 id 可能是数字也可能是字符串 —— 这里统一成字符串。
|
|
993
|
+
const probe = Object.assign({}, edge, {
|
|
994
|
+
from: edge.from === undefined || edge.from === null ? edge.from : String(edge.from),
|
|
995
|
+
to: edge.to === undefined || edge.to === null ? edge.to : String(edge.to),
|
|
996
|
+
})
|
|
997
|
+
const style = styleOf(edge)
|
|
998
|
+
// 自环:两端在同一个盒子上。storedRoute 会把两端裁到同一条边上、剩下的点不足两个,
|
|
999
|
+
// 整条边会静默消失;这里照客户端 selfLoopPath 的形状单独画(用户的折点照样穿过)。
|
|
1000
|
+
if (probe.from !== undefined && probe.from !== null && String(probe.from) === String(probe.to)) {
|
|
1001
|
+
const box = state.byId instanceof Map ? state.byId.get(String(probe.from)) : undefined
|
|
1002
|
+
if (box === undefined) return null
|
|
1003
|
+
return selfLoopRoute(box.geo, style, own)
|
|
1004
|
+
}
|
|
1005
|
+
// 没有折点的正交边:先用宿主路由器算出来的走线,再交给 storedRoute 裁两端的边框交点。
|
|
1006
|
+
// 只要有一个折点就用它(引擎常常只给一个拐点),空数组 = 这次没算出来。
|
|
1007
|
+
const solved = own.length === 0 ? state.routed.get(String(edge.id)) : null
|
|
1008
|
+
const usedSolved = Array.isArray(solved) && solved.length >= 1
|
|
1009
|
+
if (usedSolved) probe.points = solved
|
|
1010
|
+
const routed = storedRoute(probe, boxes)
|
|
1011
|
+
if (routed === null || routed.length < 2) return null
|
|
1012
|
+
const orthogonal = isOrthogonalEdgeStyle(style) === true && curvedFromStyle(style) !== true
|
|
1013
|
+
if (own.length > 0 || usedSolved) {
|
|
1014
|
+
// 存下来的折点只当"必经点",拐角要自己补 —— 否则会画出斜线段。
|
|
1015
|
+
return orthogonal ? orthogonalizeRoute(routed, style) : routed
|
|
1016
|
+
}
|
|
1017
|
+
// 引擎没接上(或不是正交边)时,退化成一条 Z 线,并在 warnings 里说清楚这次是估算。
|
|
1018
|
+
if (curvedFromStyle(style) === true) return routed
|
|
1019
|
+
const a = routed[0]
|
|
1020
|
+
const b = routed[routed.length - 1]
|
|
1021
|
+
if (Math.abs(b.x - a.x) < 0.5 || Math.abs(b.y - a.y) < 0.5) return routed
|
|
1022
|
+
state.warnings.push('连线 "' + String(edge.id) + '" 没有折点,正交走线按 Z 形近似')
|
|
1023
|
+
if (Math.abs(b.x - a.x) >= Math.abs(b.y - a.y)) {
|
|
1024
|
+
const mx = (a.x + b.x) / 2
|
|
1025
|
+
return [a, { x: mx, y: a.y }, { x: mx, y: b.y }, b]
|
|
1026
|
+
}
|
|
1027
|
+
const my = (a.y + b.y) / 2
|
|
1028
|
+
return [a, { x: a.x, y: my }, { x: b.x, y: my }, b]
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function markerIdFor(color, state) {
|
|
1032
|
+
if (state.markers.has(color)) return state.markers.get(color)
|
|
1033
|
+
const id = 'drawai-export-arrow-' + state.markers.size
|
|
1034
|
+
state.markers.set(color, id)
|
|
1035
|
+
return id
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function edgeSvg(edge, boxes, palette, state) {
|
|
1039
|
+
const style = styleOf(edge)
|
|
1040
|
+
const pts = routeForEdge(edge, boxes, state)
|
|
1041
|
+
if (pts === null) {
|
|
1042
|
+
state.warnings.push('连线 "' + String(edge.id) + '" 的两端都落不到任何节点上,已跳过')
|
|
1043
|
+
return null
|
|
1044
|
+
}
|
|
1045
|
+
const stroke = styleGet(style, 'strokeColor', null)
|
|
1046
|
+
const lineStroke = stroke !== null ? stroke : palette.line
|
|
1047
|
+
const curved = curvedFromStyle(style) === true
|
|
1048
|
+
// drawio 的圆角半径:arcSize(缺省 20)再除以 2 → 缺省 10px。
|
|
1049
|
+
const cornerRadius = styleGet(style, 'rounded', '0') === '1' ? Math.max(0, styleNumber(style, 'arcSize', 20) / 2) : 0
|
|
1050
|
+
|
|
1051
|
+
const labelFontSize = styleNumber(style, 'fontSize', 10)
|
|
1052
|
+
const hasLabel = typeof edge.label === 'string' && edge.label.length > 0
|
|
1053
|
+
let ownPos = hasLabel ? edgeLabelPointAt(pts, edge.labelX, edge.labelY, edge.labelOffsetX, edge.labelOffsetY) : null
|
|
1054
|
+
if (ownPos === null && hasLabel) ownPos = { x: pts[0].x, y: pts[0].y }
|
|
1055
|
+
const ownBox = ownPos === null ? null : labelBox(ownPos, edge.label, labelFontSize)
|
|
1056
|
+
|
|
1057
|
+
const gaps = []
|
|
1058
|
+
if (ownBox !== null) gaps.push.apply(gaps, labelGapsOnPath(pts, growBox(ownBox, 2)))
|
|
1059
|
+
const attached = Array.isArray(state.labels) ? state.labels : []
|
|
1060
|
+
for (let L = 0; L < attached.length; L += 1) {
|
|
1061
|
+
const item = attached[L]
|
|
1062
|
+
if (item === null || typeof item !== 'object' || String(item.edgeId) !== String(edge.id)) continue
|
|
1063
|
+
const text = typeof item.text === 'string' ? item.text : ''
|
|
1064
|
+
if (text.length === 0) continue
|
|
1065
|
+
const pos = edgeLabelPointAt(pts, item.x, item.y, item.offsetX, item.offsetY)
|
|
1066
|
+
if (pos === null) continue
|
|
1067
|
+
gaps.push.apply(gaps, labelGapsOnPath(pts, growBox(labelBox(pos, text, styleNumber(item.style, 'fontSize', 10)), 2)))
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const subPaths = gaps.length > 0 ? cutPathByGaps(pts, gaps) : [pts]
|
|
1071
|
+
const d = subPaths.map((sub) => pathOf(sub, cornerRadius, curved)).filter((text) => text.length > 0).join(' ')
|
|
1072
|
+
if (d.length === 0) return null
|
|
1073
|
+
|
|
1074
|
+
const arrow = arrowFromStyle(style)
|
|
1075
|
+
const dash = dashPatternFromStyle(style)
|
|
1076
|
+
const strokeOpacity = Math.max(0, Math.min(100, styleNumber(style, 'strokeOpacity', 100))) / 100
|
|
1077
|
+
const parts = []
|
|
1078
|
+
parts.push('<path' + attrs({
|
|
1079
|
+
d: d,
|
|
1080
|
+
fill: 'none',
|
|
1081
|
+
stroke: lineStroke,
|
|
1082
|
+
'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
1083
|
+
'stroke-opacity': strokeOpacity < 1 ? fmt(strokeOpacity) : null,
|
|
1084
|
+
'stroke-dasharray': dash,
|
|
1085
|
+
'stroke-linejoin': curved ? 'round' : null,
|
|
1086
|
+
'marker-end': arrow === 'none' || arrow === 'start' ? null : 'url(#' + markerIdFor(lineStroke, state) + '-end)',
|
|
1087
|
+
'marker-start': arrow === 'both' || arrow === 'start' ? 'url(#' + markerIdFor(lineStroke, state) + '-start)' : null,
|
|
1088
|
+
}) + '/>')
|
|
1089
|
+
|
|
1090
|
+
if (ownBox !== null) {
|
|
1091
|
+
const ownBg = styleGet(style, 'labelBackgroundColor', null)
|
|
1092
|
+
if (ownBg !== null) {
|
|
1093
|
+
const bg = parseCssColor(ownBg, palette.labelBg)
|
|
1094
|
+
parts.push('<rect' + attrs({
|
|
1095
|
+
x: fmt(ownBox.x), y: fmt(ownBox.y), width: fmt(ownBox.w), height: fmt(ownBox.h), rx: 2,
|
|
1096
|
+
fill: bg.color, 'fill-opacity': bg.opacity < 1 ? fmt(bg.opacity) : null,
|
|
1097
|
+
}) + '/>')
|
|
1098
|
+
}
|
|
1099
|
+
const fontColor = styleGet(style, 'fontColor', null)
|
|
1100
|
+
parts.push(textSvg({
|
|
1101
|
+
x: ownPos.x,
|
|
1102
|
+
top: ownPos.y + 1 - (labelLineHeight(labelFontSize) * String(edge.label).split('\n').length) / 2,
|
|
1103
|
+
lines: String(edge.label).split('\n'),
|
|
1104
|
+
fontSize: labelFontSize,
|
|
1105
|
+
lineHeight: labelLineHeight(labelFontSize),
|
|
1106
|
+
anchor: 'middle',
|
|
1107
|
+
fill: fontColor !== null ? fontColor : palette.text,
|
|
1108
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
1109
|
+
}))
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
for (let L = 0; L < attached.length; L += 1) {
|
|
1113
|
+
const item = attached[L]
|
|
1114
|
+
if (item === null || typeof item !== 'object' || String(item.edgeId) !== String(edge.id)) continue
|
|
1115
|
+
const text = typeof item.text === 'string' ? item.text : ''
|
|
1116
|
+
if (text.length === 0) continue
|
|
1117
|
+
const pos = edgeLabelPointAt(pts, item.x, item.y, item.offsetX, item.offsetY)
|
|
1118
|
+
if (pos === null) continue
|
|
1119
|
+
const fontSize = styleNumber(item.style, 'fontSize', 10)
|
|
1120
|
+
const box = labelBox(pos, text, fontSize)
|
|
1121
|
+
parts.push('<rect' + attrs({
|
|
1122
|
+
x: fmt(box.x), y: fmt(box.y), width: fmt(box.w), height: fmt(box.h), rx: 2,
|
|
1123
|
+
fill: palette.labelBg, 'fill-opacity': 0.85,
|
|
1124
|
+
}) + '/>')
|
|
1125
|
+
parts.push(textSvg({
|
|
1126
|
+
x: pos.x,
|
|
1127
|
+
top: pos.y + 1 - (labelLineHeight(fontSize) * text.split('\n').length) / 2,
|
|
1128
|
+
lines: text.split('\n'),
|
|
1129
|
+
fontSize: fontSize,
|
|
1130
|
+
lineHeight: labelLineHeight(fontSize),
|
|
1131
|
+
anchor: 'middle',
|
|
1132
|
+
fill: styleGet(item.style, 'fontColor', null) || palette.text,
|
|
1133
|
+
opacity: 1,
|
|
1134
|
+
}))
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return { svg: parts.join(''), points: pts, labelBox: ownBox }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ── 组装 ────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
function normalizeOptions(options) {
|
|
1143
|
+
const raw = options !== null && typeof options === 'object' ? options : {}
|
|
1144
|
+
const padding = Number.isFinite(Number(raw.padding)) ? Math.max(0, Number(raw.padding)) : DEFAULT_PADDING
|
|
1145
|
+
// `background:'transparent'` 要能压过主题缺省那层白纸 —— 只写 `background:null` 分不清
|
|
1146
|
+
// "没给" 与 "明确要透明",缺省分支会把白底又铺回来。
|
|
1147
|
+
const transparent = raw.background === 'transparent' || raw.background === null
|
|
1148
|
+
return {
|
|
1149
|
+
padding: padding,
|
|
1150
|
+
theme: raw.theme === 'dark' ? 'dark' : 'light',
|
|
1151
|
+
transparent: transparent,
|
|
1152
|
+
background:
|
|
1153
|
+
typeof raw.background === 'string' && raw.background.length > 0 && raw.background !== 'transparent'
|
|
1154
|
+
? raw.background
|
|
1155
|
+
: null,
|
|
1156
|
+
fontFamily: typeof raw.fontFamily === 'string' && raw.fontFamily.length > 0 ? raw.fontFamily : SVG_FONT,
|
|
1157
|
+
title: typeof raw.title === 'string' && raw.title.length > 0 ? raw.title : '',
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 文档对象 → SVG 文本。
|
|
1163
|
+
*
|
|
1164
|
+
* @returns {{ svg: string, width: number, height: number, viewBox: object, warnings: string[], stats: object }}
|
|
1165
|
+
*/
|
|
1166
|
+
export function buildExportSvg(doc, options) {
|
|
1167
|
+
const opts = normalizeOptions(options)
|
|
1168
|
+
const palette = opts.theme === 'dark' ? DARK : LIGHT
|
|
1169
|
+
const warnings = []
|
|
1170
|
+
const boxes = exportBoxes(doc)
|
|
1171
|
+
const byId = new Map()
|
|
1172
|
+
for (let i = 0; i < boxes.length; i += 1) byId.set(boxes[i].id, boxes[i])
|
|
1173
|
+
|
|
1174
|
+
let minX = Infinity
|
|
1175
|
+
let minY = Infinity
|
|
1176
|
+
let maxX = -Infinity
|
|
1177
|
+
let maxY = -Infinity
|
|
1178
|
+
const put = (x, y) => {
|
|
1179
|
+
const px = Number(x)
|
|
1180
|
+
const py = Number(y)
|
|
1181
|
+
if (Number.isFinite(px) === false || Number.isFinite(py) === false) return
|
|
1182
|
+
if (px < minX) minX = px
|
|
1183
|
+
if (py < minY) minY = py
|
|
1184
|
+
if (px > maxX) maxX = px
|
|
1185
|
+
if (py > maxY) maxY = py
|
|
1186
|
+
}
|
|
1187
|
+
const putRect = (rect) => {
|
|
1188
|
+
if (rect === null || rect === undefined) return
|
|
1189
|
+
put(rect.x, rect.y)
|
|
1190
|
+
put(rect.x + rect.w, rect.y + rect.h)
|
|
1191
|
+
}
|
|
1192
|
+
const putBounds = (bounds) => {
|
|
1193
|
+
if (bounds === null || bounds === undefined) return
|
|
1194
|
+
put(bounds.minX, bounds.minY)
|
|
1195
|
+
put(bounds.maxX, bounds.maxY)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const body = []
|
|
1199
|
+
for (let i = 0; i < boxes.length; i += 1) {
|
|
1200
|
+
const box = boxes[i]
|
|
1201
|
+
const shape = shapeSvg(box.node, box.geo, palette, warnings)
|
|
1202
|
+
// 标签必须与形状同组:跨节点叠放时,后画的节点矩形不能盖住前一个节点的字
|
|
1203
|
+
// (客户端每个节点就是一个 <g>,形状与标签都在里面)。
|
|
1204
|
+
const text = nodeLabelSvg(box.node, box.geo, palette)
|
|
1205
|
+
if (shape.length > 0 || text.length > 0) {
|
|
1206
|
+
body.push('<g' + attrs({ 'data-node-id': box.id }) + '>' + shape + text + '</g>')
|
|
1207
|
+
}
|
|
1208
|
+
putRect(box.geo)
|
|
1209
|
+
putBounds(labelPaintBounds(box.node, box.geo))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const hidden = hiddenLayerIds(doc)
|
|
1213
|
+
const edges = Array.isArray(doc.edges) ? doc.edges : []
|
|
1214
|
+
const state = {
|
|
1215
|
+
warnings: warnings,
|
|
1216
|
+
labels: Array.isArray(doc.labels) ? doc.labels : [],
|
|
1217
|
+
markers: new Map(),
|
|
1218
|
+
byId: byId,
|
|
1219
|
+
routed: solveMissingRoutes(edges, boxes, warnings),
|
|
1220
|
+
}
|
|
1221
|
+
// 只数"真的画出来"的边:隐藏图层里的、两端都找不到节点的都不算 —— 否则 stats 会虚高,
|
|
1222
|
+
// exportTool 的"画布是空的"判定也会被一张实际只有隐藏单元的图骗过去。
|
|
1223
|
+
let paintedEdges = 0
|
|
1224
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
1225
|
+
const edge = edges[i]
|
|
1226
|
+
if (edge === null || typeof edge !== 'object') continue
|
|
1227
|
+
if (isHiddenCell(hidden, edge)) continue
|
|
1228
|
+
const painted = edgeSvg(edge, boxes, palette, state)
|
|
1229
|
+
if (painted === null) continue
|
|
1230
|
+
body.push('<g' + attrs({ 'data-edge-id': String(edge.id) }) + '>' + painted.svg + '</g>')
|
|
1231
|
+
for (let k = 0; k < painted.points.length; k += 1) put(painted.points[k].x, painted.points[k].y)
|
|
1232
|
+
putRect(painted.labelBox)
|
|
1233
|
+
paintedEdges += 1
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (Number.isFinite(minX) === false) {
|
|
1237
|
+
minX = 0
|
|
1238
|
+
minY = 0
|
|
1239
|
+
maxX = 1
|
|
1240
|
+
maxY = 1
|
|
1241
|
+
}
|
|
1242
|
+
const width = Math.max(1, maxX - minX + opts.padding * 2)
|
|
1243
|
+
const height = Math.max(1, maxY - minY + opts.padding * 2)
|
|
1244
|
+
const viewX = minX - opts.padding
|
|
1245
|
+
const viewY = minY - opts.padding
|
|
1246
|
+
|
|
1247
|
+
// 箭头 marker:每种线色一份(客户端用同一个 skin 色,彩色的线会配一个黑箭头,
|
|
1248
|
+
// 静态图里看着像画错了 —— 这里按线自己的颜色出)。
|
|
1249
|
+
const markers = []
|
|
1250
|
+
for (const color of state.markers.keys()) {
|
|
1251
|
+
const id = state.markers.get(color)
|
|
1252
|
+
markers.push('<marker' + attrs({
|
|
1253
|
+
id: id + '-end', viewBox: '0 0 10 10', refX: 9.5, refY: 5,
|
|
1254
|
+
markerWidth: 6, markerHeight: 6, orient: 'auto',
|
|
1255
|
+
}) + '><path' + attrs({ d: 'M 0 0 L 10 5 L 0 10 z', fill: color }) + '/></marker>')
|
|
1256
|
+
markers.push('<marker' + attrs({
|
|
1257
|
+
id: id + '-start', viewBox: '0 0 10 10', refX: 0.5, refY: 5,
|
|
1258
|
+
markerWidth: 6, markerHeight: 6, orient: 'auto',
|
|
1259
|
+
}) + '><path' + attrs({ d: 'M 10 0 L 0 5 L 10 10 z', fill: color }) + '/></marker>')
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const background = opts.background !== null ? opts.background : opts.transparent === true || opts.theme === 'dark' ? null : palette.page
|
|
1263
|
+
const head = [
|
|
1264
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1265
|
+
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' + attrs({
|
|
1266
|
+
width: fmt(Math.round(width * 100) / 100),
|
|
1267
|
+
height: fmt(Math.round(height * 100) / 100),
|
|
1268
|
+
viewBox: [viewX, viewY, width, height].map(fmt).join(' '),
|
|
1269
|
+
}) + '>',
|
|
1270
|
+
]
|
|
1271
|
+
if (opts.title.length > 0) head.push('<title>' + escapeXml(opts.title) + '</title>')
|
|
1272
|
+
if (markers.length > 0) head.push('<defs>' + markers.join('') + '</defs>')
|
|
1273
|
+
if (background !== null) {
|
|
1274
|
+
head.push('<rect' + attrs({ x: fmt(viewX), y: fmt(viewY), width: fmt(width), height: fmt(height), fill: background }) + '/>')
|
|
1275
|
+
}
|
|
1276
|
+
const svg = head.join('\n') + '\n' + body.join('\n') + '\n</svg>\n'
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
svg: svg,
|
|
1280
|
+
width: Math.round(width * 100) / 100,
|
|
1281
|
+
height: Math.round(height * 100) / 100,
|
|
1282
|
+
viewBox: { x: viewX, y: viewY, width: width, height: height },
|
|
1283
|
+
warnings: warnings,
|
|
1284
|
+
stats: { nodes: boxes.length, edges: paintedEdges },
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** 倍数的兜底(与客户端 exportOutputSize 同一组阈值):没给/不是数字 → 2,0 与负数 → 0.1。 */
|
|
1289
|
+
export function exportScaleFor(scale) {
|
|
1290
|
+
return Number.isFinite(Number(scale)) ? Math.max(0.1, Number(scale)) : 2
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** 从 `<svg …>` 头里读基准尺寸(buildExportSvg 一定会写 width/height)。 */
|
|
1294
|
+
function svgBaseSize(svg) {
|
|
1295
|
+
const head = String(svg === undefined || svg === null ? '' : svg)
|
|
1296
|
+
const end = head.indexOf('>')
|
|
1297
|
+
const open = end > 0 ? head.slice(0, end) : head
|
|
1298
|
+
const width = /\bwidth="([0-9.]+)"/.exec(open)
|
|
1299
|
+
const height = /\bheight="([0-9.]+)"/.exec(open)
|
|
1300
|
+
if (width === null || height === null) return null
|
|
1301
|
+
const w = Number(width[1])
|
|
1302
|
+
const h = Number(height[1])
|
|
1303
|
+
if (Number.isFinite(w) === false || Number.isFinite(h) === false || w <= 0 || h <= 0) return null
|
|
1304
|
+
return { width: w, height: h }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** 尺寸上限(单边 MAX_SIDE、总量 MAX_PIXELS),超了直接给一句能读懂的话。 */
|
|
1308
|
+
function assertRasterFits(width, height) {
|
|
1309
|
+
if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
|
|
1310
|
+
throw new Error('PNG 尺寸过大(' + width + '×' + height + ',上限 ' + MAX_SIDE + 'px/边、' +
|
|
1311
|
+
Math.round(MAX_PIXELS / 1000000) + 'MP);请调小 scale')
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* SVG → PNG。resvg 是可选的原生依赖(装不上时 SVG 那条路照走),所以按需动态 import。
|
|
1317
|
+
*
|
|
1318
|
+
* @returns {Promise<{ png: Buffer, width: number, height: number, scale: number }>}
|
|
1319
|
+
*/
|
|
1320
|
+
export async function rasterizeSvgToPng(svg, options) {
|
|
1321
|
+
const raw = options !== null && typeof options === 'object' ? options : {}
|
|
1322
|
+
let Resvg = null
|
|
1323
|
+
try {
|
|
1324
|
+
const mod = await import('@resvg/resvg-js')
|
|
1325
|
+
Resvg = mod.Resvg || (mod.default && mod.default.Resvg) || null
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
throw new Error('PNG 导出需要 @resvg/resvg-js(' + String(error && error.message ? error.message : error) + ');也可以用 format:"svg" 直接拿到矢量图')
|
|
1328
|
+
}
|
|
1329
|
+
if (typeof Resvg !== 'function') throw new Error('@resvg/resvg-js 的 Resvg 不可用')
|
|
1330
|
+
|
|
1331
|
+
const scale = exportScaleFor(raw.scale)
|
|
1332
|
+
// 先按基准尺寸算一遍像素数再栅格化:resvg 一次性分配整张位图,先 render 再检查等于让
|
|
1333
|
+
// 宿主进程先顶着内存跑一遍 —— 超大 scale 下那是 OOM,不是一句能读懂的错误。
|
|
1334
|
+
const base = svgBaseSize(svg)
|
|
1335
|
+
if (base !== null) {
|
|
1336
|
+
assertRasterFits(Math.max(1, Math.round(base.width * scale)), Math.max(1, Math.round(base.height * scale)))
|
|
1337
|
+
}
|
|
1338
|
+
const probe = new Resvg(svg, { fitTo: { mode: 'zoom', value: scale }, font: { loadSystemFonts: true } })
|
|
1339
|
+
const size = probe.render()
|
|
1340
|
+
// 兜底:resvg 自己的取整可能与上面的估算差一像素。
|
|
1341
|
+
assertRasterFits(size.width, size.height)
|
|
1342
|
+
const png = size.asPng()
|
|
1343
|
+
return { png: png, width: size.width, height: size.height, scale: scale }
|
|
1344
|
+
}
|