Flash/Core/Display.cs
using System;
using System.Collections.Generic;
namespace Flash;
/// <summary>Anything on the Flash display list.</summary>
public abstract class DisplayObject : AsObject
{
public readonly Player Player;
public MovieClip Parent;
public CharDef Def;
public int Depth;
public string Name = "";
public Matrix Matrix = Matrix.Identity;
public ColorTransform Cx = ColorTransform.Identity;
public List<Filter> Filters;
public bool Visible = true;
public int ClipDepth; // >0: this object masks depths (Depth, ClipDepth]
public int Ratio;
public int BlendMode;
public bool Removed;
/// <summary>Placed by a timeline (vs. created by script) and the frame it was placed on.</summary>
public bool FromTimeline;
public int PlacedFrame;
/// <summary>Once script moves a timeline object, the timeline stops moving it.</summary>
public bool ScriptTransformed;
public DisplayObject MaskedBy; // setMask
public DisplayObject MaskFor;
/// <summary>For objects inside a button state (they have no Parent clip).</summary>
public ButtonObj ButtonOwner;
/// <summary>Original timeline depth (tag depth); -1 once script moved it with swapDepths.</summary>
public int TimelineDepth = -1;
public DisplayObject RenderParent => (DisplayObject)Parent ?? ButtonOwner;
/// <summary>Layout tweak from the editor (F2 / FlashLayoutItem); applied on top of the SWF's own transform.</summary>
public LayoutAdjust Adjust;
/// <summary>The transform used for drawing and hit testing (SWF transform + layout tweak).</summary>
public Matrix EffectiveMatrix
{
get
{
if ( Adjust == null || Adjust.IsIdentity ) return Matrix;
var a = Adjust;
double r = a.Rotation * Math.PI / 180.0;
var local = new Matrix( Math.Cos( r ) * a.ScaleX, Math.Sin( r ) * a.ScaleX, -Math.Sin( r ) * a.ScaleY, Math.Cos( r ) * a.ScaleY, 0, 0 );
var m = Matrix.Mul( local );
m.Tx += a.X; m.Ty += a.Y;
return m;
}
}
/// <summary>A path that stays the same between runs (unnamed timeline objects are named by symbol and depth).</summary>
public string StablePath
{
get
{
var parts = new List<string>();
for ( DisplayObject d = this; d != null && d.RenderParent != null; d = d.RenderParent )
{
bool auto = string.IsNullOrEmpty( d.Name ) || (d.FromTimeline && d.Name.StartsWith( "instance", StringComparison.Ordinal ));
parts.Add( auto ? $"#{d.Def?.Id ?? 0}@{d.TimelineDepth}" : d.Name );
}
parts.Reverse();
return string.Join( "/", parts );
}
}
protected DisplayObject( Player p, AsObject proto ) : base( proto ) { Player = p; }
public MovieClip AsClip => this as MovieClip;
public virtual bool HasOwnToString => false;
public string TargetPath
{
get
{
if ( Parent == null ) return "_level0";
var parts = new List<string>();
DisplayObject d = this;
while ( d != null && d.Parent != null ) { parts.Add( d.Name ); d = d.Parent; }
parts.Reverse();
return "_level0." + string.Join( ".", parts );
}
}
public string SlashPath
{
get
{
if ( Parent == null ) return "/";
var parts = new List<string>();
DisplayObject d = this;
while ( d != null && d.Parent != null ) { parts.Add( d.Name ); d = d.Parent; }
parts.Reverse();
return "/" + string.Join( "/", parts );
}
}
public Matrix WorldMatrix
{
get
{
var m = EffectiveMatrix;
var p = RenderParent;
int g = 0;
while ( p != null && g++ < 256 ) { m = p.EffectiveMatrix.Mul( m ); p = p.RenderParent; }
return m;
}
}
public bool IsVisibleInTree
{
get
{
for ( DisplayObject d = this; d != null; d = d.RenderParent ) if ( !d.Visible || d.Removed ) return false;
return true;
}
}
// Flash keeps (scaleX, scaleY, rotationX, rotationY) alongside the matrix so _xscale/_rotation round-trip exactly
bool decomposed;
double sx, sy, rx, ry;
void Decompose()
{
if ( decomposed ) return;
sx = Math.Sqrt( Matrix.A * Matrix.A + Matrix.B * Matrix.B );
sy = Math.Sqrt( Matrix.C * Matrix.C + Matrix.D * Matrix.D );
rx = Math.Atan2( Matrix.B, Matrix.A );
ry = Math.Atan2( -Matrix.C, Matrix.D );
decomposed = true;
}
void Recompose()
{
Matrix.A = sx * Math.Cos( rx );
Matrix.B = sx * Math.Sin( rx );
Matrix.C = -sy * Math.Sin( ry );
Matrix.D = sy * Math.Cos( ry );
ScriptTransformed = true;
}
public double X { get => Matrix.Tx; set { Matrix.Tx = value; ScriptTransformed = true; } }
public double Y { get => Matrix.Ty; set { Matrix.Ty = value; ScriptTransformed = true; } }
public double XScale
{
get { Decompose(); return sx * 100.0; }
set { Decompose(); sx = value / 100.0; Recompose(); }
}
public double YScale
{
get { Decompose(); return sy * 100.0; }
set { Decompose(); sy = value / 100.0; Recompose(); }
}
public double Rotation
{
get { Decompose(); return rx * 180.0 / Math.PI; }
set
{
Decompose();
var v = value % 360.0;
if ( v > 180 ) v -= 360; else if ( v < -180 ) v += 360;
double nr = v * Math.PI / 180.0;
ry += nr - rx;
rx = nr;
Recompose();
}
}
public void SetMatrixFromTimeline( Matrix m )
{
Matrix = m;
decomposed = false;
}
public void InvalidateTransformCache() { decomposed = false; }
public double Alpha
{
get => Cx.Am * 100.0;
set { Cx.Am = value / 100.0; ScriptTransformed = true; }
}
/// <summary>Local bounds (own coordinate space) of this object's content.</summary>
public abstract (double x0, double y0, double x1, double y1)? LocalBounds();
public (double x0, double y0, double x1, double y1)? BoundsIn( Matrix m )
{
var b = LocalBounds();
if ( b is null ) return null;
var (x0, y0, x1, y1) = b.Value;
double mx0 = double.MaxValue, my0 = double.MaxValue, mx1 = double.MinValue, my1 = double.MinValue;
foreach ( var (px, py) in new[] { (x0, y0), (x1, y0), (x0, y1), (x1, y1) } )
{
var (tx, ty) = m.Apply( px, py );
mx0 = Math.Min( mx0, tx ); my0 = Math.Min( my0, ty ); mx1 = Math.Max( mx1, tx ); my1 = Math.Max( my1, ty );
}
return (mx0, my0, mx1, my1);
}
public double Width
{
get { var b = BoundsIn( Matrix ); return b is null ? 0 : b.Value.x1 - b.Value.x0; }
set
{
var b = LocalBounds();
if ( b is null ) return;
double w = b.Value.x1 - b.Value.x0;
if ( w <= 0 ) return;
// approximation that matches Flash for unrotated objects
XScale = value / w * 100.0 * Math.Sign( XScale == 0 ? 1 : XScale );
}
}
public double Height
{
get { var b = BoundsIn( Matrix ); return b is null ? 0 : b.Value.y1 - b.Value.y0; }
set
{
var b = LocalBounds();
if ( b is null ) return;
double h = b.Value.y1 - b.Value.y0;
if ( h <= 0 ) return;
YScale = value / h * 100.0 * Math.Sign( YScale == 0 ? 1 : YScale );
}
}
/// <summary>Point hit test in stage coordinates against actual content.</summary>
public bool HitTestPoint( double sx, double sy, bool shapeOnly )
{
var (lx, ly) = WorldMatrix.Inverse().Apply( sx, sy );
return HitLocal( lx, ly, shapeOnly );
}
/// <summary>Hit test in this object's own coordinate space.</summary>
public abstract bool HitLocal( double lx, double ly, bool shapeOnly );
public bool HitChild( double plx, double ply, bool shapeOnly )
{
if ( !Visible || (Adjust != null && Adjust.Hidden) ) return false;
var (lx, ly) = EffectiveMatrix.Inverse().Apply( plx, ply );
return HitLocal( lx, ly, shapeOnly );
}
// ------------------------------------------------------------------ AS property access
static readonly string[] IndexedNames = { "_x", "_y", "_xscale", "_yscale", "_currentframe", "_totalframes", "_alpha", "_visible", "_width", "_height",
"_rotation", "_target", "_framesloaded", "_name", "_droptarget", "_url", "_highquality", "_focusrect", "_soundbuftime", "_quality", "_xmouse", "_ymouse" };
public Value GetIndexedProperty( int idx, Avm1 vm ) => idx >= 0 && idx < IndexedNames.Length ? Get( IndexedNames[idx], vm ) : Value.Undefined;
public void SetIndexedProperty( int idx, Value v, Avm1 vm ) { if ( idx >= 0 && idx < IndexedNames.Length ) Set( IndexedNames[idx], v, vm ); }
protected bool GetDisplayProperty( string name, Avm1 vm, out Value v )
{
switch ( name )
{
case "_x": v = Math.Round( X * 20 ) / 20; return true;
case "_y": v = Math.Round( Y * 20 ) / 20; return true;
case "_xscale": v = XScale; return true;
case "_yscale": v = YScale; return true;
case "_alpha": v = Alpha; return true;
case "_visible": v = Visible; return true;
case "_width": v = Math.Round( Width * 20 ) / 20; return true;
case "_height": v = Math.Round( Height * 20 ) / 20; return true;
case "_rotation": v = Rotation; return true;
case "_name": v = Name; return true;
case "_target": v = SlashPath; return true;
case "_parent": v = Parent != null ? Value.From( Parent ) : Value.Undefined; return true;
case "_root": v = Value.From( Player.Root ); return true;
case "_global": v = Value.From( vm.Global ); return true;
case "_url": v = "ss2.swf"; return true;
case "_quality": v = "HIGH"; return true;
case "_highquality": v = 1; return true;
case "_focusrect": v = Value.Null; return true;
case "_soundbuftime": v = 5; return true;
case "_droptarget": v = Player.DropTarget?.SlashPath ?? ""; return true;
case "_xmouse":
case "_ymouse":
{
var inv = WorldMatrix.Inverse();
var (mx, my) = inv.Apply( Player.MouseX, Player.MouseY );
v = Math.Round( (name == "_xmouse" ? mx : my) * 20 ) / 20;
return true;
}
}
v = default;
return false;
}
protected bool SetDisplayProperty( string name, Value v, Avm1 vm )
{
switch ( name )
{
case "_x": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) X = n; return true; }
case "_y": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) Y = n; return true; }
case "_xscale": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) XScale = n; return true; }
case "_yscale": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) YScale = n; return true; }
case "_alpha": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) Alpha = n; return true; }
case "_visible": Visible = v.IsNumber ? v.Num != 0 : v.ToBool( vm.SwfVersion ); return true;
case "_width": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) Width = n; return true; }
case "_height": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) Height = n; return true; }
case "_rotation": { var n = v.ToNumber( vm ); if ( !double.IsNaN( n ) ) Rotation = n; return true; }
case "_name": Name = v.ToStr( vm ); return true;
case "_target": case "_url": case "_droptarget": case "_xmouse": case "_ymouse": case "_parent": case "_root": case "_currentframe": case "_totalframes": case "_framesloaded":
return true;
case "_quality": case "_highquality": case "_focusrect": case "_soundbuftime": return true;
}
return false;
}
static readonly HashSet<string> displayProps = new( IndexedNames ) { "_parent", "_root", "_global" };
protected bool IsDisplayProperty( string name ) => displayProps.Contains( name );
/// <summary>Called when removed from the display list.</summary>
public virtual void OnRemoved()
{
Removed = true;
if ( MaskFor != null ) { MaskFor.MaskedBy = null; MaskFor = null; }
if ( MaskedBy != null ) { MaskedBy.MaskFor = null; MaskedBy = null; }
}
public virtual void RemoveFromScript()
{
// removeMovieClip only works on script depths (>= 0)
if ( Parent == null || Depth < 0 ) return;
Parent.RemoveChild( this );
}
public virtual void Duplicate( string name, int depth ) { }
/// <summary>Called by the player each frame for edit text variable sync etc.</summary>
public virtual void Sync() { }
}
// ================================================================================== leaf objects
public sealed class ShapeObj : DisplayObject
{
public ShapeDef Shape => (ShapeDef)Def;
public ShapeObj( Player p, ShapeDef d ) : base( p, p.Vm.ObjectProto ) { Def = d; }
public override (double x0, double y0, double x1, double y1)? LocalBounds() => (Shape.X0, Shape.Y0, Shape.X1, Shape.Y1);
public override bool HitLocal( double lx, double ly, bool shapeOnly )
{
var s = Shape;
if ( lx < s.X0 || ly < s.Y0 || lx > s.X1 || ly > s.Y1 ) return false;
if ( !shapeOnly ) return true;
return s.Mask == null || s.Mask.Test( lx, ly );
}
}
public sealed class MorphObj : DisplayObject
{
public MorphDef Morph => (MorphDef)Def;
public MorphObj( Player p, MorphDef d ) : base( p, p.Vm.ObjectProto ) { Def = d; }
public double T => Ratio / 65535.0;
public override (double x0, double y0, double x1, double y1)? LocalBounds()
{
var a = Morph.B0; var b = Morph.B1; double t = T;
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t);
}
public override bool HitLocal( double lx, double ly, bool shapeOnly )
{
var b = LocalBounds().Value;
return lx >= b.x0 && ly >= b.y0 && lx <= b.x1 && ly <= b.y1;
}
}
public sealed class StaticTextObj : DisplayObject
{
public StaticTextDef Text => (StaticTextDef)Def;
public StaticTextObj( Player p, StaticTextDef d ) : base( p, p.Vm.ObjectProto ) { Def = d; }
public override (double x0, double y0, double x1, double y1)? LocalBounds() => (Text.X0, Text.Y0, Text.X1, Text.Y1);
public override bool HitLocal( double lx, double ly, bool shapeOnly )
{
return lx >= Text.X0 && ly >= Text.Y0 && lx <= Text.X1 && ly <= Text.Y1;
}
}
/// <summary>A dynamic or input text field.</summary>
public sealed class EditText : DisplayObject
{
public EditTextDef Edit => (EditTextDef)Def;
public string Text = "";
public string HtmlSource; // when html
public bool IsHtml;
public double[] TextColor;
public string VariableName;
string lastVarText;
public bool Selectable = true;
public string Restrict;
public int MaxChars;
public int Version; // bumps when the text changes (renderers can cache)
public EditText( Player p, EditTextDef d ) : base( p, p.Vm.TextFieldProto )
{
Def = d;
IsHtml = d.Html;
TextColor = d.Color;
VariableName = d.Variable;
MaxChars = d.MaxLength;
var init = d.InitialText ?? "";
if ( IsHtml ) SetHtml( init ); else Text = init.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
}
public override (double x0, double y0, double x1, double y1)? LocalBounds() => (Edit.X0, Edit.Y0, Edit.X1, Edit.Y1);
public override bool HitLocal( double lx, double ly, bool shapeOnly )
{
return lx >= Edit.X0 && ly >= Edit.Y0 && lx <= Edit.X1 && ly <= Edit.Y1;
}
public void SetText( string t )
{
t = (t ?? "").Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
if ( IsHtml ) { HtmlSource = t; Text = HtmlText.Strip( t ); }
else Text = t;
Version++;
}
public void SetHtml( string h )
{
HtmlSource = h ?? "";
Text = HtmlText.Strip( HtmlSource ).Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
Version++;
}
/// <summary>Pull the bound variable into the field (Flash does this when the variable changes).</summary>
public override void Sync()
{
if ( string.IsNullOrEmpty( VariableName ) || Parent == null ) return;
var vm = Player.Vm;
Value v;
var name = VariableName;
AsObject holder = Parent;
int dot = Math.Max( name.LastIndexOf( '.' ), name.LastIndexOf( ':' ) );
if ( dot >= 0 )
{
var path = name.Substring( 0, dot );
var d = vm.ResolveTargetPath( Parent, path );
holder = d;
name = name.Substring( dot + 1 );
}
if ( holder == null ) return;
v = holder.Get( name, vm );
if ( v.IsUndefined )
{
if ( !holder.Has( name ) )
{
// the field's initial text initialises the variable (Flash behaviour)
holder.Set( name, IsHtml ? (Value)(HtmlSource ?? "") : Text, vm );
lastVarText = IsHtml ? HtmlSource : Text;
}
return;
}
var s = v.ToStr( vm );
if ( s == lastVarText ) return;
lastVarText = s;
if ( IsHtml ) SetHtml( s ); else { Text = s.Replace( "\r\n", "\n" ).Replace( '\r', '\n' ); Version++; }
}
/// <summary>User typed into an input field.</summary>
public void UserInput( string t )
{
if ( MaxChars > 0 && t.Length > MaxChars ) t = t.Substring( 0, MaxChars );
Text = t; Version++;
if ( !string.IsNullOrEmpty( VariableName ) && Parent != null )
{
var name = VariableName;
AsObject holder = Parent;
int dot = Math.Max( name.LastIndexOf( '.' ), name.LastIndexOf( ':' ) );
if ( dot >= 0 ) { holder = Player.Vm.ResolveTargetPath( Parent, name.Substring( 0, dot ) ); name = name.Substring( dot + 1 ); }
holder?.Set( name, t, Player.Vm );
lastVarText = t;
}
Player.Vm.CallMethod( Value.From( this ), "onChanged", Value.From( this ) );
}
protected override bool TryGetNative( string name, Avm1 vm, out Value v )
{
if ( GetDisplayProperty( name, vm, out v ) ) return true;
switch ( name )
{
case "text": v = Text; return true;
case "htmlText": v = IsHtml ? (HtmlSource ?? Text) : Text; return true;
case "html": v = IsHtml; return true;
case "textColor": v = (double)(((int)TextColor[0] << 16) | ((int)TextColor[1] << 8) | (int)TextColor[2]); return true;
case "length": v = Text.Length; return true;
case "variable": v = VariableName is null ? Value.Null : (Value)VariableName; return true;
case "selectable": v = Selectable; return true;
case "restrict": v = Restrict is null ? Value.Null : (Value)Restrict; return true;
case "maxChars": v = MaxChars == 0 ? Value.Null : (Value)MaxChars; return true;
case "multiline": v = Edit.Multiline; return true;
case "wordWrap": v = Edit.WordWrap; return true;
case "textWidth": v = Text.Length * Edit.Size * 0.5; return true;
case "textHeight": v = Edit.Size * 1.2; return true;
}
return false;
}
protected override bool TrySetNative( string name, Value v, Avm1 vm )
{
if ( SetDisplayProperty( name, v, vm ) ) return true;
switch ( name )
{
case "text": SetText( v.ToStr( vm ) ); if ( IsHtml ) { Text = v.ToStr( vm ); HtmlSource = null; } return true;
case "htmlText": if ( IsHtml ) SetHtml( v.ToStr( vm ) ); else SetText( v.ToStr( vm ) ); return true;
case "html": IsHtml = v.ToBool( vm.SwfVersion ); Version++; return true;
case "textColor":
{
int c = v.ToInt32( vm );
TextColor = new double[] { (c >> 16) & 255, (c >> 8) & 255, c & 255, 255 };
Version++;
return true;
}
case "variable": VariableName = v.IsNullish ? null : v.ToStr( vm ); lastVarText = null; return true;
case "selectable": Selectable = v.ToBool( vm.SwfVersion ); return true;
case "restrict": Restrict = v.IsNullish ? null : v.ToStr( vm ); return true;
case "maxChars": MaxChars = v.IsNullish ? 0 : v.ToInt32( vm ); return true;
}
return false;
}
protected override bool HasNativeProperty( string name ) => IsDisplayProperty( name ) || name is "text" or "htmlText" or "textColor" or "length" or "variable";
}
/// <summary>A SWF button (DefineButton2): up/over/down states and on(...) actions.</summary>
public sealed class ButtonObj : DisplayObject
{
public ButtonDef Button => (ButtonDef)Def;
public string State = "u"; // u, o, d
public bool Enabled = true;
public bool UseHandCursor = true;
public readonly Dictionary<string, List<DisplayObject>> StateChildren = new();
public ButtonObj( Player p, ButtonDef d ) : base( p, p.Vm.ButtonProto )
{
Def = d;
foreach ( var st in new[] { "u", "o", "d", "h" } )
{
var list = new List<DisplayObject>();
foreach ( var r in d.Records )
{
if ( r.States.IndexOf( st, StringComparison.Ordinal ) < 0 ) continue;
var child = p.Instantiate( r.CharId, null );
if ( child == null ) continue;
child.Parent = null;
child.SetMatrixFromTimeline( r.M );
if ( r.Cx.HasValue ) child.Cx = r.Cx.Value;
child.Filters = r.Filters;
child.Depth = r.Depth;
child.ButtonOwner = this;
child.FromTimeline = true;
list.Add( child );
if ( child is MovieClip mc ) p.Construct( mc );
}
StateChildren[st] = list;
}
}
public List<DisplayObject> Current => StateChildren.TryGetValue( State, out var l ) ? l : null;
public override void OnRemoved()
{
base.OnRemoved();
foreach ( var l in StateChildren.Values ) foreach ( var c in l ) Player.Unload( c );
}
public override (double x0, double y0, double x1, double y1)? LocalBounds()
{
(double, double, double, double)? r = null;
foreach ( var c in StateChildren["u"] )
{
var b = c.BoundsIn( c.Matrix );
if ( b is null ) continue;
r = r is null ? b : (Math.Min( r.Value.Item1, b.Value.x0 ), Math.Min( r.Value.Item2, b.Value.y0 ), Math.Max( r.Value.Item3, b.Value.x1 ), Math.Max( r.Value.Item4, b.Value.y1 ));
}
return r;
}
public override bool HitLocal( double lx, double ly, bool shapeOnly )
{
var hit = StateChildren["h"];
if ( hit.Count == 0 ) hit = StateChildren["u"];
foreach ( var c in hit )
if ( c.HitChild( lx, ly, true ) ) return true;
return false;
}
protected override bool TryGetNative( string name, Avm1 vm, out Value v )
{
if ( GetDisplayProperty( name, vm, out v ) ) return true;
if ( name == "enabled" ) { v = Enabled; return true; }
if ( name == "useHandCursor" ) { v = UseHandCursor; return true; }
return false;
}
protected override bool TrySetNative( string name, Value v, Avm1 vm )
{
if ( SetDisplayProperty( name, v, vm ) ) return true;
if ( name == "enabled" ) { Enabled = v.ToBool( vm.SwfVersion ); return true; }
if ( name == "useHandCursor" ) { UseHandCursor = v.ToBool( vm.SwfVersion ); return true; }
return false;
}
protected override bool HasNativeProperty( string name ) => IsDisplayProperty( name );
}
/// <summary>A layout tweak: offset (in the parent's space), scale/rotation about the object's origin, or hidden.</summary>
public sealed class LayoutAdjust
{
public double X, Y, ScaleX = 1, ScaleY = 1, Rotation;
public bool Hidden;
public bool IsIdentity => X == 0 && Y == 0 && ScaleX == 1 && ScaleY == 1 && Rotation == 0 && !Hidden;
}
public static class HtmlText
{
/// <summary>Plain text of a Flash HTML string (tags removed, entities decoded, <br>/<p> as newlines).</summary>
public static string Strip( string html )
{
if ( string.IsNullOrEmpty( html ) ) return "";
var sb = new System.Text.StringBuilder();
int i = 0;
bool firstP = true;
while ( i < html.Length )
{
char c = html[i];
if ( c == '<' )
{
int e = html.IndexOf( '>', i );
if ( e < 0 ) break;
var tag = html.Substring( i + 1, e - i - 1 ).Trim().ToLowerInvariant();
if ( tag.StartsWith( "br" ) ) sb.Append( '\n' );
else if ( tag.StartsWith( "p" ) && !tag.StartsWith( "/" ) ) { if ( !firstP ) sb.Append( '\n' ); firstP = false; }
i = e + 1;
continue;
}
if ( c == '&' )
{
int e = html.IndexOf( ';', i );
if ( e > i && e - i < 8 )
{
var ent = html.Substring( i + 1, e - i - 1 );
string rep = ent switch { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => "\"", "apos" => "'", "nbsp" => " ", _ => null };
if ( rep != null ) { sb.Append( rep ); i = e + 1; continue; }
}
}
sb.Append( c );
i++;
}
return sb.ToString();
}
}