diff --git a/src/UglyToad.PdfPig.Core/PdfSubpath.cs b/src/UglyToad.PdfPig.Core/PdfSubpath.cs index 32841be5..82fbe5b8 100644 --- a/src/UglyToad.PdfPig.Core/PdfSubpath.cs +++ b/src/UglyToad.PdfPig.Core/PdfSubpath.cs @@ -18,7 +18,8 @@ public IReadOnlyList Commands => commands; /// - /// True if the was originaly draw as a rectangle. + /// True if the was originaly drawn using the rectangle ('re') operator. + /// Always false if paths are clipped. /// public bool IsDrawnAsRectangle { get; internal set; } @@ -34,7 +35,6 @@ /// /// Return true if points are organised in a counterclockwise order. Works only with closed paths. /// - /// public bool IsCounterClockwise => IsClosed() && shoeLaceSum < 0; /// diff --git a/src/UglyToad.PdfPig.Tests/Geometry/PdfPathExtensionsTests.cs b/src/UglyToad.PdfPig.Tests/Geometry/PdfPathExtensionsTests.cs new file mode 100644 index 00000000..2f27ec3d --- /dev/null +++ b/src/UglyToad.PdfPig.Tests/Geometry/PdfPathExtensionsTests.cs @@ -0,0 +1,38 @@ +namespace UglyToad.PdfPig.Tests.Geometry +{ + using System.Linq; + using UglyToad.PdfPig.Core; + using UglyToad.PdfPig.Geometry; + using UglyToad.PdfPig.Tests.Integration; + using Xunit; + + public class PdfPathExtensionsTests + { + [Fact] + public void ContainsRectangleEvenOdd() + { + using (var document = PdfDocument.Open(IntegrationHelpers.GetDocumentPath("path_ext_oddeven"), + new ParsingOptions() { ClipPaths = true })) + { + var page = document.GetPage(1); + var words = page.GetWords().ToList(); + + foreach (var path in page.ExperimentalAccess.Paths) + { + Assert.NotEqual(FillingRule.NonZeroWinding, path.FillingRule); // allow none and even-odd + + foreach (var c in words.Where(w => path.Contains(w.BoundingBox)).ToList()) + { + Assert.Equal("in", c.Text.Split("_").Last()); + words.Remove(c); + } + } + + foreach (var w in words) + { + Assert.NotEqual("in", w.Text.Split("_").Last()); + } + } + } + } +} diff --git a/src/UglyToad.PdfPig.Tests/Integration/Documents/path_ext_oddeven.pdf b/src/UglyToad.PdfPig.Tests/Integration/Documents/path_ext_oddeven.pdf new file mode 100644 index 00000000..2947742c Binary files /dev/null and b/src/UglyToad.PdfPig.Tests/Integration/Documents/path_ext_oddeven.pdf differ diff --git a/src/UglyToad.PdfPig/Geometry/ClippingExtensions.cs b/src/UglyToad.PdfPig/Geometry/ClippingExtensions.cs index 7e8221db..12d839ad 100644 --- a/src/UglyToad.PdfPig/Geometry/ClippingExtensions.cs +++ b/src/UglyToad.PdfPig/Geometry/ClippingExtensions.cs @@ -157,7 +157,7 @@ /// Converts a path to a set of points for the Clipper algorithm to use. /// Allows duplicate points as they will be removed by Clipper. /// - private static IEnumerable ToClipperPolygon(this PdfSubpath pdfPath) + internal static IEnumerable ToClipperPolygon(this PdfSubpath pdfPath) { if (pdfPath.Commands.Count == 0) { @@ -166,7 +166,7 @@ if (pdfPath.Commands[0] is Move currentMove) { - var previous = new ClipperIntPoint(currentMove.Location.X * Factor, currentMove.Location.Y * Factor); + var previous = currentMove.Location.ToClipperIntPoint(); yield return previous; @@ -190,22 +190,35 @@ if (command is Line line) { - yield return new ClipperIntPoint(line.From.X * Factor, line.From.Y * Factor); - yield return new ClipperIntPoint(line.To.X * Factor, line.To.Y * Factor); + yield return line.From.ToClipperIntPoint(); + yield return line.To.ToClipperIntPoint(); } else if (command is BezierCurve curve) { foreach (var lineB in curve.ToLines(LinesInCurve)) { - yield return new ClipperIntPoint(lineB.From.X * Factor, lineB.From.Y * Factor); - yield return new ClipperIntPoint(lineB.To.X * Factor, lineB.To.Y * Factor); + yield return lineB.From.ToClipperIntPoint(); + yield return lineB.To.ToClipperIntPoint(); } } else if (command is Close) { - yield return new ClipperIntPoint(currentMove.Location.X * Factor, currentMove.Location.Y * Factor); + yield return currentMove.Location.ToClipperIntPoint(); } } } + + internal static IEnumerable ToClipperPolygon(this PdfRectangle rectangle) + { + yield return rectangle.BottomLeft.ToClipperIntPoint(); + yield return rectangle.TopLeft.ToClipperIntPoint(); + yield return rectangle.TopRight.ToClipperIntPoint(); + yield return rectangle.BottomRight.ToClipperIntPoint(); + } + + internal static ClipperIntPoint ToClipperIntPoint(this PdfPoint point) + { + return new ClipperIntPoint(point.X * Factor, point.Y * Factor); + } } } diff --git a/src/UglyToad.PdfPig/Geometry/GeometryExtensions.cs b/src/UglyToad.PdfPig/Geometry/GeometryExtensions.cs index 1538d9f6..58f48102 100644 --- a/src/UglyToad.PdfPig/Geometry/GeometryExtensions.cs +++ b/src/UglyToad.PdfPig/Geometry/GeometryExtensions.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Text; + using UglyToad.PdfPig.Geometry.ClipperLibrary; + using UglyToad.PdfPig.Graphics; using static UglyToad.PdfPig.Core.PdfSubpath; /// @@ -57,14 +59,14 @@ } /// - /// Algorithm to find a minimal bounding rectangle (MBR) such that the MBR corresponds to a rectangle + /// Algorithm to find a minimal bounding rectangle (MBR) such that the MBR corresponds to a rectangle /// with smallest possible area completely enclosing the polygon. /// From 'A Fast Algorithm for Generating a Minimal Bounding Rectangle' by Lennert D. Den Boer. /// /// /// Polygon P is assumed to be both simple and convex, and to contain no duplicate (coincident) vertices. - /// The vertices of P are assumed to be in strict cyclic sequential order, either clockwise or - /// counter-clockwise relative to the origin P0. + /// The vertices of P are assumed to be in strict cyclic sequential order, either clockwise or + /// counter-clockwise relative to the origin P0. /// private static PdfRectangle ParametricPerpendicularProjection(IReadOnlyList polygon) { @@ -180,7 +182,7 @@ return new PdfRectangle(new PdfPoint(MBR[4], MBR[5]), new PdfPoint(MBR[6], MBR[7]), - new PdfPoint(MBR[2], MBR[3]), + new PdfPoint(MBR[2], MBR[3]), new PdfPoint(MBR[0], MBR[1])); } @@ -191,7 +193,7 @@ /// The points. public static PdfRectangle MinimumAreaRectangle(IEnumerable points) { - if (points == null || points.Count() == 0) + if (points?.Any() != true) { throw new ArgumentException("MinimumAreaRectangle(): points cannot be null and must contain at least one point.", nameof(points)); } @@ -208,7 +210,7 @@ { if (points == null || points.Count < 2) { - throw new ArgumentException("OrientedBoundingBox(): points cannot be null and must contain at least two points."); + throw new ArgumentException("OrientedBoundingBox(): points cannot be null and must contain at least two points.", nameof(points)); } // Fitting a line through the points @@ -250,8 +252,7 @@ cos, sin, 0, -sin, cos, 0, 0, 0, 1); - var obb = rotateBack.Transform(aabb); - return obb; + return rotateBack.Transform(aabb); } /// @@ -259,9 +260,9 @@ /// public static IEnumerable GrahamScan(IEnumerable points) { - if (points == null || points.Count() == 0) + if (points?.Any() != true) { - throw new ArgumentException("GrahamScan(): points cannot be null and must contain at least one point."); + throw new ArgumentException("GrahamScan(): points cannot be null and must contain at least one point.", nameof(points)); } if (points.Count() < 3) return points; @@ -321,7 +322,7 @@ #region PdfRectangle /// - /// Whether the rectangle contains the point. + /// Whether the point is located inside the rectangle. /// /// The rectangle that should contain the point. /// The point that should be contained within the rectangle. @@ -370,7 +371,7 @@ } /// - /// Whether the rectangle contains the rectangle. + /// Whether the other rectangle is located inside the rectangle. /// /// The rectangle that should contain the other rectangle. /// The other rectangle that should be contained within the rectangle. @@ -432,12 +433,12 @@ if (IntersectsWith(rectangle.BottomLeft, rectangle.BottomRight, other.BottomRight, other.TopRight)) return true; if (IntersectsWith(rectangle.BottomLeft, rectangle.BottomRight, other.TopRight, other.TopLeft)) return true; if (IntersectsWith(rectangle.BottomLeft, rectangle.BottomRight,other.TopLeft, other.BottomLeft)) return true; - + if (IntersectsWith(rectangle.BottomRight, rectangle.TopRight, other.BottomLeft, other.BottomRight)) return true; if (IntersectsWith(rectangle.BottomRight, rectangle.TopRight,other.BottomRight, other.TopRight)) return true; if (IntersectsWith(rectangle.BottomRight, rectangle.TopRight, other.TopRight, other.TopLeft)) return true; if (IntersectsWith(rectangle.BottomRight, rectangle.TopRight, other.TopLeft, other.BottomLeft)) return true; - + if (IntersectsWith(rectangle.TopRight, rectangle.TopLeft, other.BottomLeft, other.BottomRight)) return true; if (IntersectsWith(rectangle.TopRight, rectangle.TopLeft, other.BottomRight, other.TopRight)) return true; if (IntersectsWith(rectangle.TopRight, rectangle.TopLeft, other.TopRight, other.TopLeft)) return true; @@ -454,6 +455,7 @@ /// /// Gets the that is the intersection of two rectangles. + /// Only works for axis-aligned rectangles. /// public static PdfRectangle? Intersect(this PdfRectangle rectangle, PdfRectangle other) { @@ -477,7 +479,7 @@ #region PdfLine /// - /// Whether the line segment contains the point. + /// Whether the point is located on the line segment. /// public static bool Contains(this PdfLine line, PdfPoint point) { @@ -535,7 +537,7 @@ #region Path Line /// - /// Whether the line segment contains the point. + /// Whether the point is located on the line segment. /// public static bool Contains(this Line line, PdfPoint point) { @@ -736,7 +738,7 @@ { return Intersect(bezierCurve, line.From, line.To); } - + private static PdfPoint[] Intersect(BezierCurve bezierCurve, PdfPoint p1, PdfPoint p2) { var ts = IntersectT(bezierCurve, p1, p2); @@ -811,7 +813,254 @@ var solution = SolveCubicEquation(a, b, c, d); - return solution.Where(s => !double.IsNaN(s)).Where(s => s >= -epsilon && (s - 1) <= epsilon).OrderBy(s => s).ToArray(); + return solution.Where(s => !double.IsNaN(s) && s >= -epsilon && (s - 1) <= epsilon).OrderBy(s => s).ToArray(); + } + #endregion + + #region PdfPath & PdfSubpath + #region Clipper extension + // https://stackoverflow.com/questions/54723622/point-in-polygon-hit-test-algorithm + // Ported from Angus Johnson's Delphi Pascal code (Clipper's author) + // Might be made available in the next Clipper release? + + private static double CrossProduct(ClipperIntPoint pt1, ClipperIntPoint pt2, ClipperIntPoint pt3) + { + return (pt2.X - pt1.X) * (pt3.Y - pt2.Y) - (pt2.Y - pt1.Y) * (pt3.X - pt2.X); + } + + /// + /// nb: returns MaxInt ((2^32)-1) when pt is on a line + /// + private static int PointInPathsWindingCount(ClipperIntPoint pt, List> paths) + { + var result = 0; + for (int i = 0; i < paths.Count; i++) + { + int j = 0; + List p = paths[i]; + int len = p.Count; + + if (len < 3) continue; + ClipperIntPoint prevPt = p[len - 1]; + + while ((j < len) && (p[j].Y == prevPt.Y)) j++; + if (j == len) continue; + + bool isAbove = prevPt.Y < pt.Y; + + while (j < len) + { + if (isAbove) + { + while ((j < len) && (p[j].Y < pt.Y)) j++; + if (j == len) + { + break; + } + else if (j > 0) + { + prevPt = p[j - 1]; + } + + double crossProd = CrossProduct(prevPt, p[j], pt); + if (crossProd == 0) + { + return int.MaxValue; + } + else if (crossProd < 0) + { + result--; + } + } + else + { + while ((j < len) && (p[j].Y > pt.Y)) j++; + if (j == len) + { + break; + } + else if (j > 0) + { + prevPt = p[j - 1]; + } + + double crossProd = CrossProduct(prevPt, p[j], pt); + if (crossProd == 0) + { + return int.MaxValue; + } + else if (crossProd > 0) + { + result++; + } + } + + j++; + isAbove = !isAbove; + } + } + return result; + } + + private static bool PointInPaths(ClipperIntPoint pt, List> paths, ClipperPolyFillType fillRule, bool includeBorder) + { + int wc = PointInPathsWindingCount(pt, paths); + if (wc == int.MaxValue) + { + return includeBorder; + } + + switch (fillRule) + { + default: + case ClipperPolyFillType.EvenOdd: + return wc % 2 != 0; + + case ClipperPolyFillType.NonZero: + return wc != 0; + } + } + #endregion + + /// + /// Whether the point is located inside the subpath. + /// Ignores winding rule. + /// + /// The subpath that should contain the point. + /// The point that should be contained within the subpath. + /// If set to false, will return false if the point belongs to the subpath's border. + public static bool Contains(this PdfSubpath subpath, PdfPoint point, bool includeBorder = false) + { + return PointInPaths(point.ToClipperIntPoint(), + new List>() { subpath.ToClipperPolygon().ToList() }, + ClipperPolyFillType.EvenOdd, + includeBorder); + } + + /// + /// Whether the rectangle is located inside the subpath. + /// Ignores winding rule. + /// + /// The subpath that should contain the rectangle. + /// The rectangle that should be contained within the subpath. + /// If set to false, will return false if the rectangle is on the subpath's border. + public static bool Contains(this PdfSubpath subpath, PdfRectangle rectangle, bool includeBorder = false) + { + // NB, For later dev: Might not work for concave outer subpath, as it can contain all the points of the rectangle, but have overlapping edges. + var clipperPaths = new List>() { subpath.ToClipperPolygon().ToList() }; + foreach (var point in rectangle.ToClipperPolygon()) + { + if (!PointInPaths(point, clipperPaths, ClipperPolyFillType.EvenOdd, includeBorder)) return false; + } + + return true; + } + + /// + /// Whether the other subpath is located inside the subpath. + /// Ignores winding rule. + /// + /// The subpath that should contain the rectangle. + /// The other subpath that should be contained within the subpath. + /// If set to false, will return false if the other subpath is on the subpath's border. + public static bool Contains(this PdfSubpath subpath, PdfSubpath other, bool includeBorder = false) + { + // NB, For later dev: Might not work for concave outer subpath, as it can contain all the points of the inner subpath, but have overlapping edges. + var clipperPaths = new List>() { subpath.ToClipperPolygon().ToList() }; + foreach (var pt in other.ToClipperPolygon()) + { + if (!PointInPaths(pt, clipperPaths, ClipperPolyFillType.EvenOdd, includeBorder)) return false; + } + return true; + } + + /// + /// Get the area of the path. + /// + /// + public static double GetArea(this PdfPath path) + { + var clipperPaths = path.Select(sp => sp.ToClipperPolygon().ToList()).ToList(); + var simplifieds = Clipper.SimplifyPolygons(clipperPaths, path.FillingRule == FillingRule.NonZeroWinding ? ClipperPolyFillType.NonZero : ClipperPolyFillType.EvenOdd); + double sum = 0; + foreach (var simplified in simplifieds) + { + sum += Clipper.Area(simplified); + } + return sum; + } + + /// + /// Whether the point is located inside the path. + /// + /// The path that should contain the point. + /// The point that should be contained within the path. + /// If set to false, will return false if the point belongs to the path's border. + public static bool Contains(this PdfPath path, PdfPoint point, bool includeBorder = false) + { + var clipperPaths = path.Select(sp => sp.ToClipperPolygon().ToList()).ToList(); + return PointInPaths(point.ToClipperIntPoint(), + clipperPaths, + path.FillingRule == FillingRule.NonZeroWinding ? ClipperPolyFillType.NonZero : ClipperPolyFillType.EvenOdd, + includeBorder); + } + + /// + /// Whether the rectangle is located inside the path. + /// + /// The path that should contain the rectangle. + /// The rectangle that should be contained within the path. + /// If set to false, will return false if the rectangle is on the path's border. + public static bool Contains(this PdfPath path, PdfRectangle rectangle, bool includeBorder = false) + { + // NB, For later dev: Might not work for concave outer path, as it can contain all the points of the inner rectangle, but have overlapping edges. + var clipperPaths = path.Select(sp => sp.ToClipperPolygon().ToList()).ToList(); + var fillType = path.FillingRule == FillingRule.NonZeroWinding ? ClipperPolyFillType.NonZero : ClipperPolyFillType.EvenOdd; + foreach (var point in rectangle.ToClipperPolygon()) + { + if (!PointInPaths(point, clipperPaths, fillType, includeBorder)) return false; + } + + return true; + } + + /// + /// Whether the subpath is located inside the path. + /// + /// The path that should contain the subpath. + /// The subpath that should be contained within the path. + /// If set to false, will return false if the subpath is on the path's border. + public static bool Contains(this PdfPath path, PdfSubpath subpath, bool includeBorder = false) + { + // NB, For later dev: Might not work for concave outer path, as it can contain all the points of the inner subpath, but have overlapping edges. + var clipperPaths = path.Select(sp => sp.ToClipperPolygon().ToList()).ToList(); + var fillType = path.FillingRule == FillingRule.NonZeroWinding ? ClipperPolyFillType.NonZero : ClipperPolyFillType.EvenOdd; + foreach (var p in subpath.ToClipperPolygon()) + { + if (!PointInPaths(p, clipperPaths, fillType, includeBorder)) return false; + } + return true; + } + + /// + /// Whether the other path is located inside the path. + /// + /// The path that should contain the path. + /// The other path that should be contained within the path. + /// If set to false, will return false if the other subpath is on the path's border. + public static bool Contains(this PdfPath path, PdfPath other, bool includeBorder = false) + { + // NB, For later dev: Might not work for concave outer path, as it can contain all the points of the inner path, but have overlapping edges. + var clipperPaths = path.Select(sp => sp.ToClipperPolygon().ToList()).ToList(); + var fillType = path.FillingRule == FillingRule.NonZeroWinding ? ClipperPolyFillType.NonZero : ClipperPolyFillType.EvenOdd; + foreach (var subpath in other) + { + foreach (var p in subpath.ToClipperPolygon()) + { + if (!PointInPaths(p, clipperPaths, fillType, includeBorder)) return false; + } + } + return true; } #endregion @@ -938,8 +1187,7 @@ { string BboxToRect(PdfRectangle box, string stroke) { - var overallBbox = $""; - return overallBbox; + return $""; } var glyph = p.ToSvg(height); @@ -958,9 +1206,7 @@ var path = $""; var bboxRect = bbox.HasValue ? BboxToRect(bbox.Value, "yellow") : string.Empty; var others = string.Join(" ", bboxes.Select(x => BboxToRect(x, "gray"))); - var result = $"{path} {bboxRect} {others}"; - - return result; + return $"{path} {bboxRect} {others}"; } } }